Coverage for functions \ flipdare \ error \ formatted_stripe_error.py: 87%

105 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# 

12from __future__ import annotations 

13from dataclasses import dataclass 

14from typing import Self 

15import stripe._error as s_error 

16 

17from flipdare.generated.shared.app_payment_error_code import AppPaymentErrorCode 

18 

19__all__ = ["FormattedStripeError"] 

20 

21 

22@dataclass 

23class FormattedStripeError: 

24 """Formatted Stripe error with extracted parameters""" 

25 

26 error_code: AppPaymentErrorCode 

27 http_code: int | None 

28 

29 stripe_error_code: str | None = None 

30 stripe_error_type: str | None = None 

31 message: str | None = None 

32 user_message: str | None = None 

33 param: str | None = None 

34 request_id: str | None = None 

35 decline_code: str | None = None # For card errors 

36 charge_id: str | None = None # For card errors 

37 raw_error: str | None = None 

38 

39 @classmethod 

40 def from_exception(cls, error: s_error.StripeError) -> Self: 

41 """Extract parameters from Stripe exception""" 

42 # Determine app error code 

43 error_code = cls._get_app_error_code(error) 

44 

45 # Extract specific properties for different error types 

46 decline_code: str | None = None 

47 charge_id: str | None = None 

48 

49 if isinstance(error, s_error.CardError): 

50 decline_code = cls._get_attr("decline_code", error) 

51 charge_id = cls._get_attr("charge", error) 

52 

53 error_obj = getattr(error, "error", None) 

54 stripe_code: str | None = None 

55 error_type: str | None = None 

56 

57 if error_obj: 

58 stripe_code = cls._get_attr("code", error_obj) 

59 error_type = cls._get_attr("type", error_obj, False) 

60 

61 if stripe_code is None: 

62 stripe_code = cls._get_attr("code", error) 

63 if error_type is None: 

64 error_type = cls._get_attr("type", error, False) 

65 

66 if stripe_code is None: 

67 json_body = getattr(error, "json_body", None) 

68 if isinstance(json_body, dict): 

69 error_body = json_body.get("error", json_body) 

70 raw_code = error_body.get("code") if isinstance(error_body, dict) else None 

71 if raw_code: 

72 stripe_code = raw_code.upper() if isinstance(raw_code, str) else str(raw_code) 

73 

74 # status is an int. 

75 status: int | None = getattr(error, "http_status", None) 

76 if not isinstance(status, int): 

77 status = ( 

78 None if isinstance(error, s_error.APIConnectionError) else error_code.http_code 

79 ) 

80 

81 # Extract common properties 

82 return cls( 

83 error_code=error_code, 

84 stripe_error_code=stripe_code, 

85 stripe_error_type=error_type, 

86 message=str(error), 

87 user_message=cls._get_attr("user_message", error), 

88 param=cls._get_attr("param", error), 

89 http_code=status, 

90 request_id=cls._get_attr("request_id", error), 

91 decline_code=decline_code, 

92 charge_id=charge_id, 

93 raw_error=str(error), 

94 ) 

95 

96 @staticmethod 

97 def _get_attr(key: str, obj: s_error.StripeError, uppercase: bool = True) -> str | None: 

98 """Helper to safely get attribute from raw error""" 

99 value = getattr(obj, key, None) 

100 if value is None: 

101 return None 

102 

103 if isinstance(value, str): 

104 return value.upper() if uppercase else value.capitalize() 

105 

106 return str(value) 

107 

108 @staticmethod 

109 def _get_app_error_code(error: s_error.StripeError) -> AppPaymentErrorCode: 

110 """Map Stripe exception to app error code""" 

111 match error: 

112 case s_error.CardError(): 

113 return AppPaymentErrorCode.CARD_ERROR 

114 case s_error.RateLimitError(): 

115 return AppPaymentErrorCode.RATE_LIMIT_ERROR 

116 case s_error.InvalidRequestError(): 

117 return AppPaymentErrorCode.INVALID_REQUEST 

118 case s_error.AuthenticationError(): 

119 return AppPaymentErrorCode.AUTH_ERROR 

120 case s_error.APIConnectionError(): 

121 return AppPaymentErrorCode.API_CONNECTION_ERROR 

122 case s_error.StripeError(): 

123 return AppPaymentErrorCode.API_ERROR 

124 

125 @property 

126 def friendly_user_message(self) -> str: 

127 """Create user-friendly message based on error details""" 

128 msg = self.decoded_error_message 

129 

130 code_msg = "" 

131 if self.stripe_error_code: 

132 code_msg += f"Stripe Code: {self.stripe_error_code}" 

133 if self.decline_code: 

134 if code_msg: 

135 code_msg += ", " 

136 code_msg += f"Decline Code: {self.decline_code}" 

137 

138 if code_msg: 

139 msg += f" ({code_msg})" 

140 

141 return msg 

142 

143 @property 

144 def is_retryable(self) -> bool: 

145 """Check if error is retryable""" 

146 if self.error_code == AppPaymentErrorCode.RATE_LIMIT_ERROR: 

147 return True 

148 

149 if self.error_code == AppPaymentErrorCode.API_CONNECTION_ERROR: 

150 return True 

151 

152 return self.stripe_error_code in ["terminal_reader_timeout", "terminal_reader_busy"] 

153 

154 @property 

155 def is_card_declined(self) -> bool: 

156 """Check if it's a card decline""" 

157 return self.error_code == AppPaymentErrorCode.CARD_ERROR and self.decline_code is not None 

158 

159 @property 

160 def decoded_error_message(self) -> str: 

161 """Get user-friendly error message""" 

162 # Fallback messages based on error type 

163 if self.is_card_declined: 

164 return "Your card was declined. Please try a different payment method." 

165 

166 if self.error_code == AppPaymentErrorCode.INVALID_REQUEST: 

167 return "There was an issue processing your payment. Please try again." 

168 

169 if self.error_code == AppPaymentErrorCode.API_CONNECTION_ERROR: 

170 return "Network error. Please check your connection and try again." 

171 

172 if self.user_message: 

173 return self.user_message 

174 

175 return "An unexpected error occurred. Please try again or contact support."