Coverage for functions \ flipdare \ service \ payments \ _payment_charge_handler.py: 9%
592 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 TYPE_CHECKING, Any, Literal
15from firebase_functions import https_fn
16from flipdare.app_log import LOG
17from flipdare.constants import IS_DEBUG, PAYMENT_MAX_INFO_RETRIES, PAYMENT_MAX_RETRIES
18from flipdare.generated import (
19 PaymentCreateResponseSchema,
20 PledgeModel,
21 PaymentEventStatus,
22 AppPaymentErrorCode,
23 PaymentConfirmResponseSchema,
24 ErrorSchema,
25 AuditInfoModel,
26 PaymentEventModel,
27 PaymentModel,
28)
29from flipdare.generated.model.payment.payment_result_model import PaymentResultModel
30from flipdare.generated.shared.payment.payment_status import PaymentStatus
31from flipdare.generated.shared.stripe.stripe_intent_status import StripeIntentStatus
32from flipdare.payments.data.app_payment_context import AppPaymentContext
33from flipdare.payments.data.payment_event_context import PaymentEventContext
34from flipdare.payments.data.payment_schedule import PaymentSchedule
35from flipdare.payments.data.payment_validator import PaymentValidator
36from flipdare.payments.dto.payment_intent_dto import PaymentIntentDTO
37from flipdare.payments.payment_types import AccountRiskInfo
38from flipdare.payments.app_stripe_proxy import AppStripeProxy
39from flipdare.error import (
40 AppError,
41 StripeErrorContext,
42)
43from flipdare.result.app_result import AppResult
44from flipdare.result.job_result import JobResult
45from flipdare.service._error_mixin import ErrorMixin
46from flipdare.request import (
47 AppRequest,
48 PaymentConfirmRequestAdapter,
49 PaymentCreateRequestAdapter,
50)
51from flipdare.service._service_provider import ServiceProvider
52from flipdare.service._user_mixin import UserMixin
53from flipdare.service.payments._base_payment_handler import BasePaymentHandler
54from flipdare.wrapper.payment.payment_event_wrapper import PaymentEventWrapper
55from flipdare.wrapper.payment.pledge_wrapper import PledgeWrapper
56from flipdare.wrapper.user_wrapper import UserWrapper
57from flipdare.payments.core.stripe_guard import StripeGuard
58from flipdare.util.slug_coder import SlugCoder
60if TYPE_CHECKING:
61 from flipdare.manager.service_manager import ServiceManager
62 from flipdare.service.payments._payment_account_handler import PaymentAccountHandler
63 from flipdare.manager.backend_manager import BackendManager
64 from flipdare.manager.db_manager import DbManager
66__all__ = ["PaymentChargeHandler"]
69class PaymentChargeHandler(BasePaymentHandler, ErrorMixin, UserMixin, ServiceProvider):
70 def __init__(
71 self,
72 proxy: AppStripeProxy,
73 account_handler: PaymentAccountHandler,
74 db_manager: DbManager | None = None,
75 backend_manager: BackendManager | None = None,
76 service_manager: ServiceManager | None = None,
77 ) -> None:
78 self.proxy = proxy
79 self.account_handler = account_handler
81 super().__init__(
82 db_manager=db_manager,
83 backend_manager=backend_manager,
84 service_manager=service_manager,
85 )
87 # ========================================================================
88 # WEB METHODS
89 # ========================================================================
91 def callable_create_charge(
92 self,
93 req: https_fn.CallableRequest[Any],
94 ) -> PaymentCreateResponseSchema | ErrorSchema:
95 request = AppRequest.callable(req)
96 endpoint = request.endpoint or "(internal)create_charge"
97 adapter: PaymentCreateRequestAdapter | None = None
98 try:
99 adapter = PaymentCreateRequestAdapter.from_callable(req)
100 adapter.validate()
101 except AppError as e:
102 msg = f"Create Charge Request validation error: {e}\n\tRequest: {request!s}"
103 LOG().error(msg)
104 error = StripeErrorContext.from_code(
105 endpoint=endpoint,
106 error_code=AppPaymentErrorCode.INVALID_REQUEST,
107 cause=msg,
108 error=e,
109 )
110 return error.to_dict()
112 customer_uid = adapter.customer_uid
113 cust_user: UserWrapper | None = None
114 try:
115 cust_user = self.get_user_by_id(endpoint=endpoint, uid=customer_uid)
116 except Exception as e:
117 msg = f"Failed to retrieve from user for uid={customer_uid}\n\tError:{e}"
118 LOG().error(msg)
119 error = StripeErrorContext.from_code(
120 endpoint=endpoint,
121 error_code=AppPaymentErrorCode.INVALID_USER,
122 cause=msg,
123 error=e,
124 )
125 return error.to_dict()
127 account_uid = adapter.account_uid
128 risk_info: AccountRiskInfo | None = None
129 try:
130 risk_info = self.get_account_risk(endpoint=endpoint, account_uid=account_uid)
131 except Exception as e:
132 msg = f"Failed to retrieve account risk info for uid={account_uid}\n\tError:{e}"
133 LOG().error(msg)
134 error = StripeErrorContext.from_code(
135 endpoint=endpoint,
136 error_code=AppPaymentErrorCode.INVALID_USER,
137 cause=msg,
138 error=e,
139 )
140 return error.to_dict()
142 customer_id = adapter.customer_id
143 currency_code = adapter.currency_code
144 email = adapter.email
145 name = adapter.name
147 audit_info = AuditInfoModel.create(
148 endpoint=endpoint,
149 uid=customer_uid,
150 ip_address=adapter.ip_address,
151 device_fingerprint=adapter.device_fingerprint,
152 )
154 if customer_id is None:
155 try:
156 customer_id = self.account_handler.create_customer(
157 endpoint=endpoint,
158 user=cust_user,
159 alt_currency_code=currency_code,
160 name=name,
161 email=email,
162 )
163 except Exception as e:
164 msg = f"Failed to create stripe customer for payment creation for user {customer_uid}\n\tError:{e}"
165 LOG().error(msg)
166 error = StripeErrorContext.from_code(
167 endpoint=endpoint,
168 error_code=AppPaymentErrorCode.INVALID_USER,
169 cause=msg,
170 error=e,
171 )
172 return error.to_dict()
174 pledge_amount = adapter.pledge_amount
175 dare_id = adapter.dare_id
176 try:
177 ctx = self._build_payment_context(
178 audit_info=audit_info,
179 risk_info=risk_info,
180 cust_user=cust_user,
181 dare_id=dare_id,
182 amount=pledge_amount,
183 )
184 payment_response = self.proxy.create_payment_intent(endpoint, ctx)
185 payment_intent_id = payment_response["payment_intent_id"]
187 self._create_pledge_in_db(
188 ctx=ctx,
189 payment_intent_id=payment_intent_id,
190 )
191 return payment_response
193 except Exception as e:
194 msg = f"Failed to create charge for user {customer_uid}\n\tError:{e}"
195 LOG().error(msg)
196 error = StripeErrorContext.from_code(
197 endpoint=endpoint,
198 error_code=AppPaymentErrorCode.PAYMENT_CREATE_FAILED,
199 cause=msg,
200 error=e,
201 )
202 return error.to_dict()
204 def callable_confirm_charge(
205 self,
206 req: https_fn.CallableRequest[Any],
207 ) -> PaymentConfirmResponseSchema | ErrorSchema:
208 # save the payment method id so it can be charged later
209 # when the user confirms the pledge in the app
210 # NOTE: we no longer cancel the intent immediately.
211 # NOTE: this is because the charge may need to be re-authorized later if the
212 # NOTE: dare/voting takes longer than the payment intent authorization hold period.
213 # NOTE: instead, we add a charge to the capture_charge_db
214 # NOTE: so we can re-authorize, delay capture etc.
216 request = AppRequest.callable(req)
217 endpoint = request.endpoint or "(internal)authorize_charge"
219 adapter: PaymentConfirmRequestAdapter | None = None
220 try:
221 adapter = PaymentConfirmRequestAdapter.from_callable(req)
222 adapter.validate()
223 except AppError as e:
224 msg = f"Authorize Charge Request validation error: {e}\n\tRequest: {request!s}"
225 LOG().error(msg)
226 error = StripeErrorContext.from_code(
227 endpoint=endpoint,
228 error_code=AppPaymentErrorCode.INVALID_REQUEST,
229 cause=msg,
230 error=e,
231 )
232 return error.to_dict()
234 customer_uid = adapter.customer_uid
235 payment_intent_id = adapter.payment_intent_id
236 payment_method_id: str | None = None
237 debug_str = f"(payment_intent_id {payment_intent_id} for user {customer_uid})"
239 # get the payment method id from the payment_intent_id
240 try:
241 intent = self.proxy.get_payment_intent(endpoint, payment_intent_id)
242 payment_method_id = intent.payment_method_id
243 except Exception as e:
244 msg = f"Failed to retrieve payment intent {debug_str}\n\tError:{e}"
245 LOG().error(msg)
246 error = StripeErrorContext.from_code(
247 endpoint=endpoint,
248 error_code=AppPaymentErrorCode.CHARGE_AUTHORIZE_FAILED,
249 cause=msg,
250 error=e,
251 )
252 return error.to_dict()
254 if payment_method_id is None:
255 msg = f"Payment intent {debug_str} has no payment method id, cannot authorize charge."
256 LOG().error(msg)
257 error = StripeErrorContext.from_code(
258 endpoint=endpoint,
259 error_code=AppPaymentErrorCode.CHARGE_AUTHORIZE_FAILED,
260 cause=msg,
261 )
262 return error.to_dict()
264 LOG().debug(f"Authorizing charge {debug_str} with payment method {payment_method_id}")
265 pledge_amount = adapter.pledge_amount
266 pledge_id = adapter.pledge_id
268 try:
269 self._update_payment_method(
270 endpoint=endpoint,
271 pledge_id=pledge_id,
272 payment_method_id=payment_method_id,
273 )
274 return PaymentConfirmResponseSchema(
275 uid=customer_uid,
276 pledge_amount=pledge_amount,
277 )
278 except Exception as e:
279 msg = f"Failed to update charge {debug_str} and payment method {payment_method_id}\n\tError:{e}"
280 LOG().error(msg)
281 error = StripeErrorContext.from_code(
282 endpoint=endpoint,
283 error_code=AppPaymentErrorCode.CHARGE_AUTHORIZE_FAILED,
284 cause=msg,
285 error=e,
286 )
287 return error.to_dict()
289 # ========================================================================
290 # CRON METHODS
291 # ========================================================================
293 def process_unprocessed_payment( # noqa: PLR0912, PLR0915
294 self,
295 pledge: PledgeWrapper,
296 ) -> JobResult[PledgeWrapper]:
297 # this is for payments in an error state, we try to re-process
298 # and if that fails we fail
299 doc_id = pledge.doc_id
300 main_result = AppResult[PledgeWrapper](
301 doc_id=doc_id, task_name=f"Pledge unprocessed for {doc_id}"
302 )
304 if pledge.payment is None:
305 msg = f"Pledge {pledge.doc_id} has no payment to process."
306 LOG().error(msg)
307 return self._build_job_result_error(
308 endpoint="(internal)process_unprocessed_payment",
309 main_result=main_result,
310 error_code=AppPaymentErrorCode.PAYMENT_MISSING,
311 message=msg,
312 pledge=pledge,
313 )
315 payment_intent_id = pledge.payment.payment_intent_id
316 endpoint = f"(internal) reauthorize_payment_intent {doc_id}/{payment_intent_id}"
318 if pledge.error_count >= PAYMENT_MAX_RETRIES:
319 msg = (
320 f"Pledge {doc_id} exceeded max retries ({pledge.error_count}), marking as failed."
321 )
322 pledge.payment_status = PaymentStatus.FAILED
323 return self._build_job_result_error(
324 endpoint=endpoint,
325 error_code=AppPaymentErrorCode.PAYMENT_MAX_RETRIES_EXCEEDED,
326 message=msg,
327 pledge=pledge,
328 main_result=main_result,
329 )
331 intent: PaymentIntentDTO | None = None
332 try:
333 intent = self.proxy.get_payment_intent(
334 endpoint=endpoint,
335 payment_intent_id=payment_intent_id,
336 )
337 except Exception as err:
338 msg = f"Failed to retrieve payment intent for reauthorization for {payment_intent_id}: {err}"
339 return self._build_job_result_error(
340 endpoint=endpoint,
341 message=msg,
342 pledge=pledge,
343 main_result=main_result,
344 )
346 debug_msg = f"(pledge={doc_id}/payment_intent_id={payment_intent_id})"
348 validator_obj = self._create_validator(endpoint, pledge, intent, main_result)
349 if isinstance(validator_obj, JobResult):
350 return validator_obj
352 validator = validator_obj
353 # first we check if we need reauthorize
354 result: JobResult[PledgeWrapper]
355 if validator.can_reauthorize:
356 result = self.reauthorize_payment(pledge)
357 if result.is_error:
358 msg = f"Failed to reauthorize payment for {debug_msg} with error: {result.message}"
359 LOG().error(msg)
360 return result
362 # then check each state and try to process accordingly.
363 if validator.needs_additional_info:
364 # not much we can do here, we wait for the user to update their payment
365 # method or whatever is required.
366 additional_ct = pledge.additional_info_count
367 if additional_ct >= PAYMENT_MAX_INFO_RETRIES:
368 msg = f"Pledge {doc_id} exceeded max additional info retries ({additional_ct}), marking as failed."
369 pledge.payment_status = PaymentStatus.FAILED
370 return self._build_job_result_error(
371 endpoint=endpoint,
372 error_code=AppPaymentErrorCode.PAYMENT_MAX_INFO_RETRIES_EXCEEDED,
373 message=msg,
374 pledge=pledge,
375 main_result=main_result,
376 )
377 else:
378 msg = f"Payment for {debug_msg} requires additional info, cannot process."
379 pledge.additional_info_count += 1
380 self._update_pledge(endpoint, pledge)
381 LOG().info(msg)
382 return JobResult.skip_doc(doc_id=doc_id, message=msg)
384 if validator.needs_capture:
385 result = self.capture_payment(pledge)
386 if result.is_error:
387 msg = f"Failed to capture payment for {debug_msg} with error: {result.message}"
388 LOG().error(msg)
389 return result
390 elif validator.needs_refund:
391 result = self.refund_payment(pledge)
392 if result.is_error:
393 msg = f"Failed to refund payment for {debug_msg} with error: {result.message}"
394 LOG().error(msg)
395 return result
396 elif validator.needs_transfer:
397 result = self.transfer_payment(pledge)
398 if result.is_error:
399 msg = f"Failed to transfer payment for {debug_msg} with error: {result.message}"
400 LOG().error(msg)
401 return result
403 # This is potentially a problem ...
404 # NOTE: we need to monitor the logs for these type of errors.
405 # NOTE: they will probably require support intervention to resolve.
406 msg = f"Payment for {debug_msg} is in an unknown state, cannot process."
407 LOG().error(msg)
408 return self._build_job_result_error(
409 endpoint=endpoint,
410 error_code=AppPaymentErrorCode.UNKNOWN_CHARGE_STATE,
411 message=msg,
412 pledge=pledge,
413 main_result=main_result,
414 )
416 def reauthorize_payment(self, pledge: PledgeWrapper) -> JobResult[PledgeWrapper]:
417 doc_id = pledge.doc_id
418 main_result = AppResult[PledgeWrapper](
419 doc_id=doc_id, task_name=f"Pledge reauthorize for {doc_id}"
420 )
422 if pledge.payment is None:
423 msg = f"Pledge {pledge.doc_id} has no payment to process."
424 LOG().error(msg)
425 return self._build_job_result_error(
426 endpoint="(internal)process_unprocessed_payment",
427 main_result=main_result,
428 error_code=AppPaymentErrorCode.PAYMENT_MISSING,
429 message=msg,
430 pledge=pledge,
431 )
433 payment_intent_id = pledge.payment.payment_intent_id
434 endpoint = f"(internal) reauthorize_payment_intent {doc_id}/{payment_intent_id}"
436 intent: PaymentIntentDTO | None = None
437 try:
438 intent = self.proxy.get_payment_intent(
439 endpoint=endpoint,
440 payment_intent_id=payment_intent_id,
441 )
442 except Exception as err:
443 msg = f"Failed to retrieve payment intent for reauthorization for {payment_intent_id}: {err}"
444 return self._build_job_result_error(
445 endpoint=endpoint,
446 message=msg,
447 pledge=pledge,
448 main_result=main_result,
449 )
451 validator_obj = self._create_validator(endpoint, pledge, intent, main_result)
452 if isinstance(validator_obj, JobResult):
453 return validator_obj
455 validator = validator_obj
456 if not validator.can_reauthorize:
457 msg = f"Payment intent for pledge {doc_id} does not need reauthorization, skipping."
458 LOG().info(msg)
459 return JobResult.skip_doc(doc_id=doc_id, message=msg)
461 if not intent.nearing_timeout:
462 msg = (
463 f"Payment intent {payment_intent_id} is not nearing timeout, cannot reauthorize yet."
464 f"(intent_status={intent.intent_status}, event_status={intent.event_status})"
465 )
466 LOG().warning(msg)
467 return JobResult.skip_doc(doc_id=doc_id, message=msg)
469 # we need to cancel the existing intent
470 # and create a new one with the same details to reauthorize the charge.
471 try:
472 self.proxy.cancel_payment_intent(
473 endpoint=endpoint,
474 payment_intent_id=payment_intent_id,
475 )
476 except Exception as err:
477 msg = f"Failed to cancel payment intent for reauthorization for {payment_intent_id}: {err}"
478 LOG().error(msg)
480 main_result.add_error(
481 error_code=AppPaymentErrorCode.API_ERROR,
482 message=msg,
483 )
484 return JobResult.from_result(
485 main_result,
486 data=pledge.to_json_dict(),
487 message=msg,
488 )
490 # if we get here we have a valid payment.
491 payment = pledge.payment
492 payment_method_id = payment.payment_method_id
493 assert payment_method_id is not None # narrowing
495 amount = payment.amount
496 customer_id = payment.customer_id
497 rcpt_currency_code = payment.customer_currency_code
498 account_id = payment.account_id
500 try:
501 self.proxy.reauthorize_payment_intent(
502 endpoint=endpoint,
503 customer_id=customer_id,
504 account_id=account_id,
505 amount=amount,
506 fee_type=payment.fee_type,
507 payment_method_id=payment_method_id,
508 rcpt_currency_code=rcpt_currency_code,
509 )
510 return JobResult.ok(
511 doc_id=doc_id,
512 message=f"Successfully reauthorized payment intent for charge {doc_id}",
513 )
514 except Exception as err:
515 msg = f"Failed to create new payment intent for reauthorization for {payment_intent_id}: {err}"
516 LOG().error(msg)
517 main_result.add_error(
518 error_code=AppPaymentErrorCode.INTENT_INVALID_DATA,
519 message=msg,
520 )
521 return JobResult.from_result(
522 main_result,
523 data=pledge.to_json_dict(),
524 message=msg,
525 )
527 def capture_payment(
528 self,
529 pledge: PledgeWrapper,
530 ) -> JobResult[PledgeWrapper]:
531 doc_id = pledge.doc_id
532 payment = pledge.payment
533 main_result = AppResult[PledgeWrapper](
534 doc_id=doc_id, task_name=f"Pledge capture for {doc_id}"
535 )
537 if payment is None:
538 msg = f"Pledge {doc_id} has no payment to process."
539 LOG().error(msg)
540 return self._build_job_result_error(
541 endpoint="(internal)process_unprocessed_payment",
542 main_result=main_result,
543 error_code=AppPaymentErrorCode.PAYMENT_MISSING,
544 message=msg,
545 pledge=pledge,
546 )
548 payment_intent_id = payment.payment_intent_id
549 endpoint = f"(internal) capture_payment {doc_id}/{payment_intent_id}"
551 intent: PaymentIntentDTO | None = None
552 try:
553 intent = self.proxy.get_payment_intent(
554 endpoint=endpoint,
555 payment_intent_id=payment_intent_id,
556 )
557 except Exception as err:
558 msg = f"Failed to retrieve payment intent for capture for {payment_intent_id}: {err}"
559 return self._build_job_result_error(
560 endpoint=endpoint,
561 message=msg,
562 pledge=pledge,
563 main_result=main_result,
564 )
566 validator_obj = self._create_validator(endpoint, pledge, intent, main_result)
567 if isinstance(validator_obj, JobResult):
568 return validator_obj
570 validator = validator_obj
571 if not validator.needs_capture:
572 msg = f"Payment intent for pledge {doc_id} cannot be captured, skipping."
573 LOG().info(msg)
574 return JobResult.skip_doc(doc_id=doc_id, message=msg)
576 payment_intent_id = payment.payment_intent_id
577 account_id = payment.account_id
579 debug_msg = f"(acct_id={account_id}/pm_id={payment_intent_id})"
581 try:
582 intent = self.proxy.CAPTURE_PAYMENT(
583 endpoint=endpoint,
584 payment_intent_id=payment_intent_id,
585 )
586 except Exception as e:
587 msg = f"Failed to charge for pledge {pledge.doc_id}: {debug_msg}\n\tError:{e}"
588 LOG().error(msg)
589 error = StripeErrorContext.from_code(
590 endpoint=endpoint,
591 error_code=AppPaymentErrorCode.PAYMENT_CAPTURE_FAILED,
592 cause=msg,
593 error=e,
594 )
595 raise AppError.from_context(error) from e
597 intent_status = intent.intent_status
598 payment_status = PaymentStatus.CAPTURE
599 if intent_status == StripeIntentStatus.CANCELED:
600 # mark as complete, we cant process any further..
601 payment_status = PaymentStatus.COMPLETE
602 msg = f"Intent {payment_intent_id} was canceled, marking payment as COMPLETE. {debug_msg}"
604 if IS_DEBUG:
605 LOG().debug(msg)
607 pledge.payment_status = payment_status
608 self._update_pledge(endpoint, pledge)
609 return JobResult.ok(doc_id=doc_id, msg=msg)
611 # process event
612 event_status = intent.event_status
613 charge_id = intent.latest_charge_id
614 result = PaymentResultModel(
615 amount_captured=intent.amount_captured or 0,
616 stripe_fee_amount=intent.stripe_fee_amount or 0,
617 app_fee_amount=intent.app_fee_amount or 0,
618 )
620 if intent_status == StripeIntentStatus.SUCCEEDED and charge_id is not None:
621 msg = f"Intent {payment_intent_id} captured successfully. {debug_msg}"
622 if IS_DEBUG:
623 LOG().debug(msg)
625 return self._add_payment_success_event(
626 endpoint=endpoint,
627 message=msg,
628 pledge=pledge,
629 intent_status=intent_status,
630 stripe_id=charge_id,
631 result=result,
632 payment_status=payment_status,
633 success_type="captured",
634 )
636 # if we get here, the charge was not successful,
637 # we need to log an error and add a payment event for the failed charge.
638 validation_msg = intent.validate_capture()
639 msg = (
640 f"Payment failed validation or incorrect status for {debug_msg}"
641 f"\n\tValidation message = {validation_msg}"
642 f"\n\tCharge ID: = {charge_id}"
643 f"\n\tIntent status = {intent_status}"
644 f"\n\tEvent status: = {event_status}"
645 )
646 LOG().error(msg)
648 return self._add_payment_error_event(
649 endpoint=endpoint,
650 message=msg,
651 pledge=pledge,
652 payment_result=result,
653 intent_status=intent_status,
654 event_status=event_status,
655 app_result=main_result,
656 payment_status=payment_status,
657 )
659 def refund_payment(
660 self,
661 pledge: PledgeWrapper,
662 ) -> JobResult[PledgeWrapper]:
663 doc_id = pledge.doc_id
664 payment = pledge.payment
665 main_result = AppResult[PledgeWrapper](
666 doc_id=doc_id, task_name=f"Pledge refund for {doc_id}"
667 )
669 if payment is None:
670 msg = f"Pledge {doc_id} has no payment to process."
671 LOG().error(msg)
672 return self._build_job_result_error(
673 endpoint="(internal)refund_payment",
674 main_result=main_result,
675 error_code=AppPaymentErrorCode.PAYMENT_MISSING,
676 message=msg,
677 pledge=pledge,
678 )
680 payment_intent_id = payment.payment_intent_id
682 endpoint = f"(internal) refund_payment {doc_id}/{payment_intent_id}"
683 main_result = AppResult[PledgeWrapper](doc_id=doc_id, task_name=f"{endpoint} for {doc_id}")
685 intent: PaymentIntentDTO | None = None
686 try:
687 intent = self.proxy.get_payment_intent(
688 endpoint=endpoint,
689 payment_intent_id=payment_intent_id,
690 )
691 except Exception as err:
692 msg = f"Failed to retrieve payment intent for refund for {payment_intent_id}: {err}"
693 return self._build_job_result_error(
694 endpoint=endpoint,
695 message=msg,
696 pledge=pledge,
697 main_result=main_result,
698 )
700 validator_obj = self._create_validator(endpoint, pledge, intent, main_result)
701 if isinstance(validator_obj, JobResult):
702 return validator_obj
704 validator = validator_obj
705 if not validator.needs_refund:
706 msg = f"Payment intent for pledge {doc_id} cannot be refunded, skipping."
707 LOG().info(msg)
708 return JobResult.skip_doc(doc_id=doc_id, message=msg)
710 events: list[PaymentEventWrapper] = []
711 try:
712 events = self.pledge_db.get_payment_events(pledge.doc_id)
713 except Exception as e:
714 msg = f"Failed to get payment events for release for pledge {doc_id}: {e}"
715 LOG().error(msg)
716 main_result.add_error(
717 error_code=AppPaymentErrorCode.API_ERROR,
718 message=msg,
719 )
720 return JobResult.from_result(
721 main_result,
722 data=pledge.to_json_dict(),
723 message=msg,
724 )
726 evt_ctx = PaymentEventContext(amount=payment.amount, events=events)
727 refund_amount = evt_ctx.refundable_amount
728 if refund_amount <= 0:
729 msg = f"Payment intent for pledge {doc_id} cannot be refunded because invalid amount ({refund_amount}), skipping."
730 LOG().info(msg)
731 return JobResult.skip_doc(doc_id=doc_id, message=msg)
733 # if we get here the payment needs to be refunded and the latest_charge_id is not None.
734 account_id = payment.account_id
736 debug_msg = f"(acct_id={account_id}/pm_id={payment_intent_id})"
738 try:
739 refund = self.proxy.REFUND_PAYMENT(
740 endpoint=endpoint,
741 customer_id=payment.customer_id,
742 obj_id=pledge.dare_id,
743 payment_intent_id=payment_intent_id,
744 refund_amount=refund_amount,
745 )
746 except Exception as e:
747 msg = f"Failed to release payment for pledge {pledge.doc_id}: {debug_msg}\n\tError:{e}"
748 LOG().error(msg)
749 error = StripeErrorContext.from_code(
750 endpoint=endpoint,
751 error_code=AppPaymentErrorCode.TRANSFER_FAILED,
752 cause=msg,
753 error=e,
754 )
755 raise AppError.from_context(error) from e
757 # if we get here the transfer was successful...
758 result = PaymentResultModel(
759 amount_refunded=refund.amount,
760 )
762 msg = f"Payment for pledge {doc_id} refunded successfully with refund {refund.id}/{refund.amount}"
763 if IS_DEBUG:
764 LOG().debug(msg)
766 return self._add_payment_success_event(
767 endpoint=endpoint,
768 message=msg,
769 pledge=pledge,
770 intent_status=StripeIntentStatus.SUCCEEDED,
771 stripe_id=refund.id,
772 result=result,
773 payment_status=PaymentStatus.COMPLETE,
774 success_type="refunded",
775 )
777 def transfer_payment(
778 self,
779 pledge: PledgeWrapper,
780 ) -> JobResult[PledgeWrapper]:
781 payment = pledge.payment
782 if payment is None:
783 msg = f"Pledge {pledge.doc_id} has no payment information, cannot transfer."
784 LOG().error(msg)
785 return self._build_job_result_error(
786 endpoint="(internal) transfer_payment",
787 message=msg,
788 pledge=pledge,
789 main_result=AppResult[PledgeWrapper](
790 doc_id=pledge.doc_id, task_name="transfer_payment"
791 ),
792 )
794 doc_id = pledge.doc_id
795 payment_intent_id = payment.payment_intent_id
797 endpoint = f"(internal) transfer_payment {doc_id}/{payment_intent_id}"
798 main_result = AppResult[PledgeWrapper](doc_id=doc_id, task_name=f"{endpoint} for {doc_id}")
800 intent: PaymentIntentDTO | None = None
801 try:
802 intent = self.proxy.get_payment_intent(
803 endpoint=endpoint,
804 payment_intent_id=payment_intent_id,
805 )
806 except Exception as err:
807 msg = f"Failed to retrieve payment intent for transfer for {payment_intent_id}: {err}"
808 return self._build_job_result_error(
809 endpoint=endpoint,
810 message=msg,
811 pledge=pledge,
812 main_result=main_result,
813 )
815 validator_obj = self._create_validator(endpoint, pledge, intent, main_result)
816 if isinstance(validator_obj, JobResult):
817 return validator_obj
819 validator = validator_obj
820 if not validator.needs_transfer:
821 msg = f"Payment intent for pledge {doc_id} cannot be transferred, skipping."
822 LOG().info(msg)
823 return JobResult.skip_doc(doc_id=doc_id, message=msg)
825 events: list[PaymentEventWrapper] = []
826 try:
827 events = self.pledge_db.get_payment_events(pledge.doc_id)
828 except Exception as e:
829 msg = f"Failed to get payment events for release for pledge {doc_id}: {e}"
830 LOG().error(msg)
831 main_result.add_error(
832 error_code=AppPaymentErrorCode.API_ERROR,
833 message=msg,
834 )
835 return JobResult.from_result(
836 main_result,
837 data=pledge.to_json_dict(),
838 message=msg,
839 )
841 evt_ctx = PaymentEventContext(amount=payment.amount, events=events)
842 transfer_amount = evt_ctx.transferrable_amount
843 if transfer_amount <= 0:
844 msg = f"Pledge ({doc_id}) has invalid transfer amount ({transfer_amount}), skipping."
845 LOG().info(msg)
846 return JobResult.skip_doc(doc_id=doc_id, message=msg)
848 # if we get here the payment needs to be transfer and the latest_charge_id is not None.
849 account_id = payment.account_id
850 transfer_currency = payment.account_currency_code
851 charge_id = payment.latest_charge_id
852 assert charge_id is not None # narrowing
854 debug_msg = f"(acct_id={account_id}/pm_id={payment_intent_id})"
856 try:
857 transfer = self.proxy.TRANSFER_PAYMENT(
858 endpoint=endpoint,
859 customer_id=payment.customer_id,
860 obj_id=pledge.dare_id,
861 account_id=account_id,
862 charge_id=charge_id,
863 transfer_amount=transfer_amount,
864 transfer_currency=transfer_currency,
865 )
866 except Exception as e:
867 msg = f"Failed to release payment for pledge {pledge.doc_id}: {debug_msg}\n\tError:{e}"
868 LOG().error(msg)
869 error = StripeErrorContext.from_code(
870 endpoint=endpoint,
871 error_code=AppPaymentErrorCode.TRANSFER_FAILED,
872 cause=msg,
873 error=e,
874 )
875 raise AppError.from_context(error) from e
877 # if we get here the transfer was successful...
878 result = PaymentResultModel(
879 amount_transferred=transfer.amount,
880 )
882 msg = f"Payment for pledge {doc_id} transferred successfully with transfer {transfer.id}/{transfer.amount}"
883 if IS_DEBUG:
884 LOG().debug(msg)
886 return self._add_payment_success_event(
887 endpoint=endpoint,
888 message=msg,
889 pledge=pledge,
890 intent_status=StripeIntentStatus.SUCCEEDED,
891 stripe_id=transfer.id,
892 result=result,
893 payment_status=PaymentStatus.COMPLETE,
894 success_type="transferred",
895 )
897 # ========================================================================
898 # HELPERS
899 # ========================================================================
901 def _create_validator(
902 self,
903 endpoint: str,
904 pledge: PledgeWrapper,
905 intent: PaymentIntentDTO,
906 main_result: AppResult[PledgeWrapper],
907 ) -> PaymentValidator | JobResult[PledgeWrapper]:
908 doc_id = pledge.doc_id
909 payment = pledge.payment
911 if payment is None:
912 msg = f"Pledge {doc_id} has no payment information, cannot validate."
913 LOG().error(msg)
914 return self._build_job_result_error(
915 endpoint=endpoint,
916 message=msg,
917 pledge=pledge,
918 main_result=main_result,
919 )
921 validator = PaymentValidator(pledge)
922 if validator.needs_refresh:
923 payment.intent_status = intent.intent_status
925 validator.set_refreshed_payment(payment)
926 if validator.needs_refresh:
927 msg = f"Payment intent for pledge {doc_id} failed to refresh, cannot reauthorize."
928 return self._build_job_result_error(
929 endpoint=endpoint,
930 message=msg,
931 pledge=pledge,
932 main_result=main_result,
933 )
935 if validator.has_errors:
936 msg = f"Payment intent for pledge {doc_id} has validation errors after refresh, cannot reauthorize."
937 return self._build_job_result_error(
938 endpoint=endpoint,
939 message=msg,
940 pledge=pledge,
941 main_result=main_result,
942 )
944 return validator
946 def _build_job_result_error(
947 self,
948 endpoint: str,
949 message: str,
950 pledge: PledgeWrapper,
951 main_result: AppResult[PledgeWrapper],
952 error_code: AppPaymentErrorCode = AppPaymentErrorCode.API_ERROR,
953 ) -> JobResult[PledgeWrapper]:
954 LOG().error(f"Charge Error for pledge {pledge.doc_id} at endpoint {endpoint}: {message}")
956 pledge.error_count += 1
957 try:
958 pledge.error_count += 1
959 self._update_pledge(endpoint, pledge)
960 except Exception as e:
961 msg = f"Failed to update pledge {pledge.doc_id} after error: {e}"
962 LOG().error(msg)
963 main_result.add_error(AppPaymentErrorCode.DATABASE_ERROR, msg, extra=str(e))
965 main_result.add_error(
966 error_code=error_code,
967 message=message,
968 )
969 return JobResult.from_result(
970 main_result,
971 data=pledge.to_json_dict(),
972 message=message,
973 )
975 def _refresh_schedule(self, endpoint: str, pledge: PledgeWrapper) -> None:
976 events: list[PaymentEventWrapper] = []
978 payment = pledge.payment
979 if payment is None:
980 msg = f"Pledge {pledge.doc_id} has no payment information, cannot validate."
981 LOG().error(msg)
982 raise AppError.from_context(
983 StripeErrorContext.from_code(
984 endpoint=endpoint,
985 error_code=AppPaymentErrorCode.PAYMENT_MISSING,
986 cause=msg,
987 )
988 )
990 try:
991 events = self.pledge_db.get_payment_events(pledge.doc_id)
992 except Exception as e:
993 msg = f"Refresh failed, failed to get events for {pledge.doc_id}\n\tError:{e}"
994 LOG().error(msg)
995 raise AppError.from_context(
996 StripeErrorContext.from_code(
997 endpoint=endpoint,
998 error_code=AppPaymentErrorCode.DATABASE_ERROR,
999 cause=msg,
1000 error=e,
1001 )
1002 ) from e
1004 schedule = PaymentSchedule(pledge.doc_id, payment, events)
1005 if not schedule.can_schedule:
1006 msg = f"Incomplete pledge info, cannot refresh payment schedule for pledge {pledge.doc_id}."
1007 LOG().error(msg)
1008 raise AppError.from_context(
1009 StripeErrorContext.from_code(
1010 endpoint=endpoint,
1011 error_code=AppPaymentErrorCode.SCHEDULE_ERROR,
1012 cause=msg,
1013 error=None,
1014 )
1015 )
1017 updated_schedule = schedule.updated_schedule
1018 updated_status = schedule.updated_status
1019 if updated_status is None and updated_schedule is None:
1020 if IS_DEBUG:
1021 msg = f"Schedule updates not required for pledge {pledge.doc_id}, skipping update."
1022 LOG().debug(msg)
1023 return
1025 if updated_schedule is not None:
1026 pledge.payment_schedule = updated_schedule
1028 if updated_status is not None:
1029 pledge.payment_status = updated_status
1031 self._update_pledge(endpoint, pledge)
1033 def _refresh_intent_status(
1034 self,
1035 endpoint: str,
1036 payment_intent_id: str,
1037 ) -> tuple[StripeIntentStatus, PaymentEventStatus] | None:
1038 try:
1039 intent = self.proxy.get_payment_intent(
1040 endpoint=endpoint,
1041 payment_intent_id=payment_intent_id,
1042 )
1043 return intent.intent_status, intent.event_status
1045 except Exception as e:
1046 msg = f"Failed to refresh payment intent status for {payment_intent_id}: {e}"
1047 LOG().error(msg)
1048 return None
1050 def _build_payment_context(
1051 self,
1052 audit_info: AuditInfoModel,
1053 risk_info: AccountRiskInfo,
1054 cust_user: UserWrapper,
1055 dare_id: str,
1056 amount: int,
1057 ) -> AppPaymentContext:
1058 return AppPaymentContext.create_from_user(
1059 audit_info=audit_info,
1060 risk_assessment=risk_info.risk_assessment,
1061 dare_id=dare_id,
1062 customer=cust_user,
1063 account=risk_info.user,
1064 amount=amount,
1065 )
1067 def get_account_risk(
1068 self,
1069 endpoint: str,
1070 account_uid: str,
1071 ) -> AccountRiskInfo:
1072 acct_user: UserWrapper | None = None
1073 try:
1074 acct_user = self.get_user_by_id(endpoint=endpoint, uid=account_uid)
1075 except Exception as e:
1076 msg = f"Failed build payment context for {account_uid}\n\tError:{e}"
1077 LOG().error(msg)
1078 raise AppError.from_context(
1079 StripeErrorContext.from_code(
1080 endpoint=endpoint,
1081 error_code=AppPaymentErrorCode.INVALID_USER,
1082 cause=msg,
1083 error=e,
1084 )
1085 ) from e
1087 settings = acct_user.stripe_settings
1088 if not StripeGuard.is_account(settings):
1089 msg = (
1090 f"User {account_uid} has invalid stripe settings, cannot build account risk info."
1091 )
1092 LOG().error(msg)
1093 raise AppError.from_context(
1094 StripeErrorContext.from_code(
1095 endpoint=endpoint,
1096 error_code=AppPaymentErrorCode.INVALID_USER,
1097 cause=msg,
1098 error=None,
1099 )
1100 )
1102 risk_assessment = self.risk_service.calculate_risk(
1103 settings=settings,
1104 fee_type=acct_user.fee_type,
1105 )
1106 return AccountRiskInfo(
1107 user=acct_user,
1108 risk_assessment=risk_assessment,
1109 )
1111 def _add_payment_success_event(
1112 self,
1113 endpoint: str,
1114 message: str,
1115 pledge: PledgeWrapper,
1116 intent_status: StripeIntentStatus,
1117 stripe_id: str,
1118 result: PaymentResultModel,
1119 payment_status: PaymentStatus,
1120 success_type: Literal["captured", "transferred", "refunded"],
1121 ) -> JobResult[PledgeWrapper]:
1122 doc_id = pledge.doc_id
1124 payment = pledge.payment
1125 if payment is None:
1126 msg = f"Pledge {doc_id} has no payment information, cannot add payment success event."
1127 LOG().error(msg)
1128 error = StripeErrorContext.from_code(
1129 endpoint=endpoint,
1130 error_code=AppPaymentErrorCode.PLEDGE_MISSING_PAYMENT,
1131 cause=msg,
1132 error=None,
1133 )
1134 raise AppError.from_context(error)
1136 evt = PaymentEventModel.success(
1137 audit_info=payment.audit_info,
1138 intent_status=intent_status,
1139 stripe_id=stripe_id,
1140 result=result,
1141 success_type=success_type,
1142 )
1144 try:
1145 self._add_payment_event_to_db(
1146 endpoint=endpoint,
1147 pledge=pledge,
1148 evt=evt,
1149 payment_status=payment_status,
1150 result=result,
1151 )
1152 self._refresh_schedule(endpoint, pledge)
1153 except Exception as e:
1154 msg = f"Failed to add payment event to db for captured charge for pledge {pledge.doc_id}\n\tError:{e}"
1155 LOG().error(msg)
1156 error = StripeErrorContext.from_code(
1157 endpoint=endpoint,
1158 error_code=AppPaymentErrorCode.DATABASE_ERROR,
1159 cause=msg,
1160 error=e,
1161 )
1162 raise AppError.from_context(error) from e
1164 return JobResult.ok(doc_id=doc_id, message=message)
1166 def _add_payment_error_event(
1167 self,
1168 endpoint: str,
1169 message: str,
1170 pledge: PledgeWrapper,
1171 intent_status: StripeIntentStatus,
1172 payment_status: PaymentStatus,
1173 event_status: PaymentEventStatus,
1174 payment_result: PaymentResultModel,
1175 app_result: AppResult[PledgeWrapper],
1176 ) -> JobResult[PledgeWrapper]:
1177 payment = pledge.payment
1178 if payment is None:
1179 msg = f"Pledge {pledge.doc_id} has no payment information, cannot add payment error event."
1180 LOG().error(msg)
1181 error = StripeErrorContext.from_code(
1182 endpoint=endpoint,
1183 error_code=AppPaymentErrorCode.PLEDGE_MISSING_PAYMENT,
1184 cause=msg,
1185 error=None,
1186 )
1187 raise AppError.from_context(error)
1189 evt = PaymentEventModel.error(
1190 audit_info=payment.audit_info,
1191 result=payment_result,
1192 error_message=message,
1193 intent_status=intent_status,
1194 status=event_status,
1195 )
1196 try:
1197 self._add_payment_event_to_db(
1198 endpoint=endpoint,
1199 pledge=pledge,
1200 evt=evt,
1201 payment_status=payment_status,
1202 result=payment_result,
1203 )
1204 except Exception as e:
1205 # we cant do much here, we hope the log parser will pick up these errors..
1206 msg = f"Failed to add payment event to db for failed charge for pledge {pledge.doc_id}: {message}\n\tError:{e}"
1207 LOG().error(msg)
1209 app_result.add_error(
1210 error_code=AppPaymentErrorCode.PAYMENT_CAPTURE_FAILED,
1211 message=message,
1212 )
1213 return JobResult.from_result(
1214 app_result,
1215 data=pledge.to_json_dict(),
1216 message=message,
1217 )
1219 # ========================================================================
1220 # DATABASE
1221 # ========================================================================
1223 def _get_pledge(self, endpoint: str, pledge_id: str) -> PledgeWrapper:
1224 # dont use pledge is not None, lint checkers dont recognize throw_ wont return
1225 pledge: PledgeWrapper | None = None
1226 try:
1227 pledge = self.pledge_db.get(pledge_id)
1228 except Exception as e:
1229 msg = f"Failed to retrieve pledge for id {pledge_id} when updating payment method\n\tError:{e}"
1230 LOG().error(msg)
1231 raise AppError.from_context(
1232 StripeErrorContext.from_code(
1233 endpoint=endpoint,
1234 error_code=AppPaymentErrorCode.DATABASE_ERROR,
1235 cause=msg,
1236 error=e,
1237 )
1238 ) from e
1240 if pledge is not None:
1241 return pledge
1243 msg = f"No pledge found for id {pledge_id} when updating payment method."
1244 LOG().error(msg)
1245 raise AppError.from_context(
1246 StripeErrorContext.from_code(
1247 endpoint=endpoint,
1248 error_code=AppPaymentErrorCode.PLEDGE_MISSING,
1249 cause=msg,
1250 )
1251 )
1253 def _get_pledge_for_payment_intent(
1254 self,
1255 endpoint: str,
1256 payment_intent_id: str,
1257 ) -> PledgeWrapper | None:
1258 try:
1259 return self.pledge_db.get_by_payment_intent_id(payment_intent_id)
1260 except Exception as e:
1261 msg = (
1262 f"Failed to retrieve pledge for payment intent id {payment_intent_id} "
1263 f"when processing charge webhook\n\tError:{e}"
1264 )
1265 LOG().error(msg)
1266 raise AppError.from_context(
1267 StripeErrorContext.from_code(
1268 endpoint=endpoint,
1269 error_code=AppPaymentErrorCode.INVALID_REQUEST,
1270 cause=msg,
1271 error=e,
1272 )
1273 ) from e
1275 def _create_pledge_in_db(
1276 self,
1277 ctx: AppPaymentContext,
1278 payment_intent_id: str,
1279 gid: str | None = None,
1280 ) -> PledgeWrapper:
1281 from_uid = ctx.customer.uid
1282 to_uid = ctx.account.uid
1283 dare_id = ctx.dare_id
1284 amount = ctx.amount
1285 currency_code = ctx.account.currency_code
1287 pledge_model = PledgeModel(
1288 id=None,
1289 slug_code=SlugCoder().from_doc_id(dare_id),
1290 from_uid=from_uid,
1291 to_uid=to_uid,
1292 dare_id=dare_id,
1293 gid=gid,
1294 amount=amount,
1295 currency_code=currency_code,
1296 payment=PaymentModel(
1297 audit_info=ctx.audit_info,
1298 risk_assessment=ctx.risk_assessment,
1299 customer_info=ctx.customer,
1300 account_info=ctx.account,
1301 payment_intent_id=payment_intent_id,
1302 amount=amount,
1303 ),
1304 )
1305 try:
1306 return self.pledge_db.create(pledge_model)
1307 except Exception as e:
1308 msg = f"Failed to create pledge in db for dare {dare_id} from {from_uid} to {to_uid}\n\tError:{e}"
1309 LOG().error(msg)
1310 raise AppError.from_context(
1311 StripeErrorContext.from_code(
1312 endpoint="(internal)_create_pledge_in_db",
1313 error_code=AppPaymentErrorCode.API_ERROR,
1314 cause=msg,
1315 error=e,
1316 )
1317 ) from e
1319 def _update_payment_method(
1320 self,
1321 endpoint: str,
1322 pledge_id: str,
1323 payment_method_id: str,
1324 ) -> PledgeWrapper | None:
1326 debug_msg = f"(pledge {pledge_id})"
1327 pledge = self._get_pledge(endpoint, pledge_id)
1329 try:
1330 pledge.payment_method_id = payment_method_id
1331 return self._update_pledge(endpoint, pledge)
1332 except Exception as e:
1333 # most likely no charge associated with the pledge
1334 msg = f"Failed to set payment method id for {debug_msg} when updating payment method\n\tError:{e}"
1335 LOG().error(msg)
1336 raise AppError.from_context(
1337 StripeErrorContext.from_code(
1338 endpoint=endpoint,
1339 error_code=AppPaymentErrorCode.INVALID_REQUEST,
1340 cause=msg,
1341 error=e,
1342 )
1343 ) from e
1345 def _update_pledge(
1346 self,
1347 endpoint: str,
1348 pledge: PledgeWrapper,
1349 ) -> PledgeWrapper | None:
1351 debug_msg = f"(pledge {pledge.doc_id})"
1352 try:
1353 updates = pledge.get_updates()
1354 if not updates:
1355 msg = f"No changes to update for pledge {debug_msg} when updating payment method."
1356 LOG().warning(msg)
1357 return None
1359 updated = self.pledge_db.update(pledge.doc_id, updates)
1360 except Exception as e:
1361 msg = f"Failed to update pledge {debug_msg} when updating payment method\n\tError:{e}"
1362 LOG().error(msg)
1363 raise AppError.from_context(
1364 StripeErrorContext.from_code(
1365 endpoint=endpoint,
1366 error_code=AppPaymentErrorCode.API_ERROR,
1367 cause=msg,
1368 error=e,
1369 )
1370 ) from e
1372 if updated is not None:
1373 return updated
1375 msg = f"Failed to update pledge {debug_msg} with new charge info when updating payment method."
1376 LOG().error(msg)
1377 raise AppError.from_context(
1378 StripeErrorContext.from_code(
1379 endpoint=endpoint,
1380 error_code=AppPaymentErrorCode.API_ERROR,
1381 cause=msg,
1382 )
1383 )
1385 def _add_payment_event_to_db(
1386 self,
1387 endpoint: str,
1388 pledge: PledgeWrapper,
1389 evt: PaymentEventModel,
1390 payment_status: PaymentStatus,
1391 result: PaymentResultModel,
1392 ) -> PaymentEventWrapper | None:
1393 # adds a charge event to the pledge db sub-collection.
1394 try:
1395 return self.pledge_db.add_payment_event(pledge, evt, payment_status, result)
1396 except Exception as e:
1397 msg = f"Failed to create charge event for pledge {pledge.doc_id} with event {evt}\n\tError:{e}"
1398 LOG().error(msg)
1399 raise AppError.from_context(
1400 StripeErrorContext.from_code(
1401 endpoint=endpoint,
1402 error_code=AppPaymentErrorCode.API_ERROR,
1403 cause=msg,
1404 error=e,
1405 )
1406 ) from e