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
« 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
17from flipdare.generated.shared.app_payment_error_code import AppPaymentErrorCode
19__all__ = ["FormattedStripeError"]
22@dataclass
23class FormattedStripeError:
24 """Formatted Stripe error with extracted parameters"""
26 error_code: AppPaymentErrorCode
27 http_code: int | None
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
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)
45 # Extract specific properties for different error types
46 decline_code: str | None = None
47 charge_id: str | None = None
49 if isinstance(error, s_error.CardError):
50 decline_code = cls._get_attr("decline_code", error)
51 charge_id = cls._get_attr("charge", error)
53 error_obj = getattr(error, "error", None)
54 stripe_code: str | None = None
55 error_type: str | None = None
57 if error_obj:
58 stripe_code = cls._get_attr("code", error_obj)
59 error_type = cls._get_attr("type", error_obj, False)
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)
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)
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 )
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 )
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
103 if isinstance(value, str):
104 return value.upper() if uppercase else value.capitalize()
106 return str(value)
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
125 @property
126 def friendly_user_message(self) -> str:
127 """Create user-friendly message based on error details"""
128 msg = self.decoded_error_message
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}"
138 if code_msg:
139 msg += f" ({code_msg})"
141 return msg
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
149 if self.error_code == AppPaymentErrorCode.API_CONNECTION_ERROR:
150 return True
152 return self.stripe_error_code in ["terminal_reader_timeout", "terminal_reader_busy"]
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
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."
166 if self.error_code == AppPaymentErrorCode.INVALID_REQUEST:
167 return "There was an issue processing your payment. Please try again."
169 if self.error_code == AppPaymentErrorCode.API_CONNECTION_ERROR:
170 return "Network error. Please check your connection and try again."
172 if self.user_message:
173 return self.user_message
175 return "An unexpected error occurred. Please try again or contact support."