Coverage for functions \ flipdare \ payments \ dto \ payment_intent_dto.py: 69%

188 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 

13 

14from typing import Self, final 

15from datetime import datetime 

16 

17from stripe import Charge 

18from stripe import PaymentIntent as StripePaymentIntent 

19from stripe._payment_method import PaymentMethod 

20from flipdare.app_log import LOG 

21from flipdare.constants import DEFAULT_REAUTHORIZATION_DAYS_CUSTOMER, IS_DEBUG 

22from flipdare.generated.shared.app_payment_error_code import AppPaymentErrorCode 

23from flipdare.generated.shared.payment.payment_event_status import PaymentEventStatus 

24from flipdare.generated.shared.stripe.stripe_intent_status import StripeIntentStatus 

25from flipdare.message.error_message import ErrorMessage 

26from flipdare.payments.data.payment_intent_codes import ( 

27 StripeCancellationCode, 

28 StripeDeclineCode, 

29 StripeIntentErrorCode, 

30) 

31from flipdare.payments.dto.charge_dto import ChargeDTO 

32from flipdare.payments.dto.safe_stripe_object import SafeStripeObject 

33from flipdare.util.time_util import TimeUtil 

34 

35 

36@final 

37class PaymentIntentDTO: 

38 """ 

39 To determine of the status you need to check "status" and a "last_payment_error": 

40 

41 Overview: 

42 

43 "canceled" 

44 - if cancellation_reason 'abandoned' or 'automatic' the intent expired. 

45 - otherwise, the user cancelled the intent. 

46 

47 "processing" 

48 - stripe is still processing the payment. 

49 

50 "requires_action" 

51 - customer authentication is required (e.g., 3D Secure, SCA) 

52 - typical last_payment_error messages are "authentication_required", "card_not_supported" 

53 

54 "requires_capture" 

55 - payment was authorized successfully but needs manual capture (capture_method: "manual") 

56 - last_payment_error is typically None since no payment failure occurred 

57 

58 "requires_confirmation" 

59 - the PaymentIntent needs to be confirmed to proceed with payment 

60 - last_payment_error is typically None since no payment attempt was made yet 

61 

62 "requires_payment_method" 

63 - the payment failed, and a new payment method is required. 

64 - typical last_payment_error decline_codes are "card_declined", "insufficient_funds", "lost_card", "stolen_card", "expired_card", "processing_error", etc. 

65 

66 "succeeded" 

67 - the payment succeeded and funds were captured. 

68 """ 

69 

70 __slots__ = ( 

71 "_amount", 

72 "_card_country", 

73 "_charge", 

74 "_client_secret", 

75 "_created", 

76 "_event_status", 

77 "_intent", 

78 "_intent_id", 

79 "_intent_status", 

80 "_latest_charge_id", 

81 "_payment_method_id", 

82 "_requires_action", 

83 "_to_currency", 

84 ) 

85 

86 _intent_id: str 

87 _amount: int 

88 _to_currency: str 

89 

90 _event_status: PaymentEventStatus 

91 _intent_status: StripeIntentStatus 

92 _requires_action: bool 

93 

94 _created: int 

95 _client_secret: str | None 

96 

97 # these exist when the payment has been authorized 

98 _charge: ChargeDTO | None 

99 _latest_charge_id: str | None 

100 _payment_method_id: str | None 

101 _card_country: str | None 

102 

103 def __init__( 

104 self, 

105 intent: StripePaymentIntent, 

106 intent_id: str, 

107 created: int, 

108 amount: int, 

109 to_currency: str, 

110 intent_status: StripeIntentStatus, 

111 event_status: PaymentEventStatus, 

112 requires_action: bool, 

113 charge: ChargeDTO | None = None, 

114 # details 

115 card_country: str | None = None, 

116 payment_method_id: str | None = None, 

117 latest_charge_id: str | None = None, 

118 # security 

119 client_secret: str | None = None, 

120 ) -> None: 

121 self._intent = intent 

122 self._intent_id = intent_id 

123 self._created = created 

124 self._amount = amount 

125 self._charge = charge 

126 self._latest_charge_id = latest_charge_id 

127 self._to_currency = to_currency 

128 self._intent_status = intent_status 

129 self._event_status = event_status 

130 self._requires_action = requires_action 

131 self._payment_method_id = payment_method_id 

132 self._card_country = card_country 

133 self._client_secret = client_secret 

134 

135 @classmethod 

136 def from_intent(cls, intent: StripePaymentIntent) -> Self: 

137 safe = SafeStripeObject(intent) 

138 intent_id = intent.id 

139 created = intent.created 

140 amount = intent.amount 

141 to_currency = intent.currency 

142 

143 latest_charge_id = None 

144 charge = None 

145 card_country = None 

146 payment_method_id = None 

147 

148 latest_charge = safe.latest_charge 

149 if latest_charge: 

150 LOG().warning(f"Latest Charge type is {type(latest_charge.unwrap())},") 

151 if isinstance(latest_charge.unwrap(), Charge): 

152 charge = ChargeDTO.from_charge(latest_charge.unwrap()) 

153 

154 if IS_DEBUG: 

155 msg = f"Have full charge object with detail {charge.debug_str()}" 

156 LOG().debug(msg) 

157 else: 

158 # latest charge is not expanded 

159 latest_charge_id = str(latest_charge) 

160 

161 payment_method = safe.payment_method 

162 if payment_method: 

163 LOG().warning(f"Payment method type is {type(payment_method.unwrap())},") 

164 if isinstance(payment_method.unwrap(), PaymentMethod): 

165 payment_method_id = payment_method.id.unwrap() 

166 card_country = payment_method.card.country.unwrap() 

167 if IS_DEBUG: 

168 msg = f"Payment method {payment_method_id} has card country {card_country}" 

169 LOG().debug(msg) 

170 else: 

171 # payment method is not expanded 

172 payment_method_id = str(payment_method) 

173 

174 client_secret = safe.client_secret.unwrap() 

175 intent_status, charge_status = cls._get_event_status(intent) 

176 

177 return cls( 

178 intent=intent, 

179 intent_id=intent_id, 

180 created=created, 

181 amount=amount, 

182 to_currency=to_currency, 

183 intent_status=intent_status, 

184 event_status=charge_status, 

185 requires_action=intent_status == StripeIntentStatus.REQUIRES_ACTION, 

186 payment_method_id=payment_method_id, 

187 card_country=card_country, 

188 charge=charge, 

189 latest_charge_id=latest_charge_id, 

190 client_secret=client_secret, 

191 ) 

192 

193 @staticmethod 

194 def _get_event_status( 

195 intent: StripePaymentIntent, 

196 ) -> tuple[StripeIntentStatus, PaymentEventStatus]: 

197 intent_status = getattr(intent, "status", None) 

198 if intent_status is None: 

199 LOG().error(f"PaymentIntent {intent.id} is missing status field!") 

200 return StripeIntentStatus.UNKNOWN, PaymentEventStatus.ERROR 

201 

202 status = StripeIntentStatus.from_literal(intent_status) 

203 if status == StripeIntentStatus.SUCCEEDED: 

204 return status, PaymentEventStatus.CAPTURED 

205 elif status == StripeIntentStatus.PROCESSING: 

206 return status, PaymentEventStatus.STRIPE_PROCESSING 

207 elif status == StripeIntentStatus.CANCELED: 

208 cancel_status = StripeCancellationCode.from_intent(intent, check_for_expired=True) 

209 event_status = ( 

210 cancel_status if cancel_status is not None else PaymentEventStatus.CANCELLED 

211 ) 

212 return status, event_status 

213 

214 # not sure what the intent status is, so we check the errors 

215 # for these intent status (not even Stripe AI knows!) 

216 decline_status = StripeDeclineCode.from_intent(intent) 

217 if decline_status is not None: 

218 # this checks for insufficient funds, we we can process.. 

219 return status, decline_status 

220 

221 intent_error_status = StripeIntentErrorCode.from_intent(intent) 

222 if intent_error_status is not None: 

223 return status, intent_error_status 

224 

225 # fallbacks 

226 match status: 

227 case StripeIntentStatus.REQUIRES_CAPTURE: 

228 return status, PaymentEventStatus.REQUIRES_CAPTURE 

229 case StripeIntentStatus.REQUIRES_CONFIRMATION | StripeIntentStatus.REQUIRES_ACTION: 

230 return status, PaymentEventStatus.REQUIRES_USER_ACTION 

231 case StripeIntentStatus.REQUIRES_PAYMENT_METHOD: 

232 return status, PaymentEventStatus.CARD_ISSUE 

233 case StripeIntentStatus.ERROR | StripeIntentStatus.UNKNOWN: 

234 return status, PaymentEventStatus.ERROR 

235 

236 @property 

237 def intent_id(self) -> str: 

238 return self._intent_id 

239 

240 @property 

241 def created(self) -> int: 

242 return self._created 

243 

244 @property 

245 def amount(self) -> int: 

246 return self._amount 

247 

248 @property 

249 def intent_status(self) -> StripeIntentStatus: 

250 return self._intent_status 

251 

252 @property 

253 def event_status(self) -> PaymentEventStatus: 

254 return self._event_status 

255 

256 @property 

257 def requires_action(self) -> bool: 

258 return self._intent_status == StripeIntentStatus.REQUIRES_ACTION 

259 

260 @property 

261 def canceled(self) -> bool: 

262 return self._intent_status == StripeIntentStatus.CANCELED 

263 

264 @property 

265 def currency(self) -> str: 

266 return self._to_currency 

267 

268 @property 

269 def latest_charge_id(self) -> str | None: 

270 return self._latest_charge_id 

271 

272 @property 

273 def payment_method_id(self) -> str | None: 

274 return self._payment_method_id 

275 

276 def validate_capture(self) -> AppPaymentErrorCode | None: 

277 if self.latest_charge_id is None: 

278 return AppPaymentErrorCode.PAYMENT_MISSING_CHARGE_ID 

279 if self.app_fee_amount is None or self.amount_captured is None: 

280 return AppPaymentErrorCode.MALFORMED_INTENT 

281 if self.amount_captured < self.app_fee_amount: 

282 return AppPaymentErrorCode.AMOUNT_TOO_SMALL 

283 return None 

284 

285 @property 

286 def card_country(self) -> str | None: 

287 return self._card_country 

288 

289 @property 

290 def client_secret(self) -> str | None: 

291 return self._client_secret 

292 

293 # ======================================================================== 

294 # CHARGE RELATED FIELDS (only available after authorization) 

295 # ======================================================================== 

296 

297 @property 

298 def amount_captured(self) -> int | None: 

299 charge = self._charge 

300 return charge.amount_captured if charge else None 

301 

302 @property 

303 def app_fee_amount(self) -> int | None: 

304 charge = self._charge 

305 return charge.app_fee_amount if charge else None 

306 

307 @property 

308 def stripe_fee_amount(self) -> int | None: 

309 charge = self._charge 

310 return charge.stripe_fee_amount if charge else None 

311 

312 @property 

313 def capture_before(self) -> datetime | None: 

314 charge = self._charge 

315 return charge.capture_before if charge else None 

316 

317 @property 

318 def nearing_timeout(self) -> bool: 

319 created = self._created 

320 dt = TimeUtil.simple_epoch_to_utc_dt(created) 

321 return TimeUtil.is_older_than(dt, days=DEFAULT_REAUTHORIZATION_DAYS_CUSTOMER - 1) 

322 

323 @property 

324 def stripe_error(self) -> tuple[str, str] | None: 

325 charge = self._charge 

326 if charge is None or not charge.is_error: 

327 return None 

328 

329 code = charge.stripe_error_code 

330 message = charge.stripe_error_message 

331 if code is None: 

332 code = ErrorMessage.STR_UNKNOWN_ERROR.value 

333 if message is None: 

334 message = ErrorMessage.STR_UNKNOWN_ERROR.value 

335 

336 return code, message 

337 

338 def debug_str(self) -> str: 

339 charge_str = self._charge.debug_str() if self._charge else "None" 

340 # append a tab to each line of the charge debug string for better readability 

341 charge_str = "\n\t\t".join(charge_str.splitlines()) 

342 

343 return ( 

344 f"PaymentIntentDTO:\n" 

345 f"\tintent_id = {self._intent_id}\n" 

346 f"\tcreated = {self._created}\n" 

347 f"\tamount = {self._amount}\n" 

348 f"\tto_currency = {self._to_currency}\n" 

349 f"\tintent_status = {self._intent_status}\n" 

350 f"\tevent_status = {self._event_status}\n" 

351 f"\tpayment_method_id = {self._payment_method_id}\n" 

352 f"\tcard_country = {self._card_country}\n" 

353 f"\tclient_secret = {'***' if self._client_secret else None}\n" 

354 f"\trequires_action = {self._requires_action}\n" 

355 f"\tcanceled = {self.canceled}\n" 

356 f"\tpayment_validation = {self.validate_capture()}\n" 

357 f"\tCharge = {charge_str}\n" 

358 )