Coverage for functions \ flipdare \ payments \ data \ payment_validator.py: 89%

353 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 flipdare.app_log import LOG 

13from flipdare.constants import IS_DEBUG 

14from flipdare.generated.model.payment.payment_model import PaymentModel 

15from flipdare.generated.shared.app_payment_error_code import AppPaymentErrorCode 

16from flipdare.generated.shared.payment.payment_status import PaymentStatus 

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

18from flipdare.wrapper.payment.pledge_wrapper import PledgeWrapper 

19from enum import Flag, auto 

20 

21 

22class PaymentState(Flag): 

23 NONE = 0 

24 INCONSISTENT = auto() 

25 IS_ERROR = auto() 

26 CAN_REAUTHORIZE = auto() 

27 NEEDS_REFRESH = auto() 

28 NEEDS_CAPTURE = auto() 

29 NEEDS_TRANSFER = auto() 

30 NEEDS_REFUND = auto() 

31 NEEDS_ADDITIONAL_INFO = auto() 

32 

33 

34class PaymentValidator: 

35 __slots__ = ( 

36 "_errors", 

37 "_payment", 

38 "_pledge", 

39 "_state", 

40 "_updated_pledge", 

41 "_warnings", 

42 ) 

43 

44 _pledge: PledgeWrapper 

45 _payment: PaymentModel 

46 _updated_pledge: PledgeWrapper | None 

47 _errors: list[tuple[AppPaymentErrorCode, str]] 

48 _warnings: list[str] 

49 _state: PaymentState 

50 

51 def __init__(self, pledge: PledgeWrapper) -> None: 

52 pledge_id = pledge.doc_id 

53 payment = pledge.payment 

54 if payment is None: 

55 raise ValueError(f"Pledge {pledge_id} has no payment to validate.") 

56 

57 self._pledge = pledge 

58 self._payment = payment 

59 self._state = PaymentState.NONE 

60 self._validate() 

61 

62 @property 

63 def payment(self) -> PaymentModel: 

64 return self._payment 

65 

66 @property 

67 def needs_refresh(self) -> bool: 

68 return bool( 

69 self._state 

70 & (PaymentState.IS_ERROR | PaymentState.INCONSISTENT | PaymentState.NEEDS_REFRESH) 

71 ) 

72 

73 @property 

74 def can_reauthorize(self) -> bool: 

75 return bool(self._state & PaymentState.CAN_REAUTHORIZE) 

76 

77 @property 

78 def needs_capture(self) -> bool: 

79 return bool(self._state & PaymentState.NEEDS_CAPTURE) 

80 

81 @property 

82 def needs_transfer(self) -> bool: 

83 return bool(self._state & PaymentState.NEEDS_TRANSFER) 

84 

85 @property 

86 def needs_additional_info(self) -> bool: 

87 return bool(self._state & PaymentState.NEEDS_ADDITIONAL_INFO) 

88 

89 @property 

90 def needs_refund(self) -> bool: 

91 return bool(self._state & PaymentState.NEEDS_REFUND) 

92 

93 @property 

94 def updated(self) -> PledgeWrapper | None: 

95 return self._updated_pledge 

96 

97 @property 

98 def has_errors(self) -> bool: 

99 return len(self._errors) > 0 or bool(self._state & PaymentState.IS_ERROR) 

100 

101 @property 

102 def _dbg_msg(self) -> str: 

103 payment = self._payment 

104 pledge_id = self._pledge.doc_id 

105 payment_method_id = payment.payment_method_id 

106 return ( 

107 f"(pledge {pledge_id}, pi_id={payment.payment_intent_id}, pm_id={payment_method_id})" 

108 ) 

109 

110 def set_refreshed_payment(self, payment: PaymentModel) -> None: 

111 self._payment = payment 

112 self._validate() 

113 

114 # ======================================================================== 

115 # MAIN ENTRY POINT 

116 # ======================================================================== 

117 

118 def _reset(self) -> None: 

119 self._updated_pledge = None 

120 self._errors = [] 

121 self._warnings = [] 

122 self._state = PaymentState.NONE 

123 

124 def _validate(self) -> None: 

125 self._reset() 

126 

127 self._updated_pledge = None 

128 self._check_consistent() 

129 self._check_can_reauthorize() 

130 self._check_needs_refresh() 

131 self._check_needs_capture() 

132 self._check_needs_transfer() 

133 self._check_needs_refund() 

134 self._check_needs_additional_info() 

135 

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

137 # CHECKS 

138 # ======================================================================== 

139 

140 def _check_needs_refresh(self) -> None: 

141 payment = self._payment 

142 payment_method_id = payment.payment_method_id 

143 

144 if payment_method_id is None: 

145 if IS_DEBUG: 

146 msg = ( 

147 f"Pledge {self._dbg_msg} has no payment method id, cannot reauthorize charge." 

148 ) 

149 LOG().debug(msg) 

150 

151 self._state |= PaymentState.NEEDS_REFRESH 

152 return 

153 

154 dispute_id = payment.stripe_dispute_id 

155 capture_before = payment.capture_before 

156 intent_status = payment.intent_status 

157 

158 if intent_status is None: 

159 if IS_DEBUG: 

160 msg = f"Pledge {self._dbg_msg} needs refresh because intent status is missing or unknown." 

161 LOG().debug(msg) 

162 

163 self._state |= PaymentState.NEEDS_REFRESH 

164 return 

165 

166 if intent_status in (StripeIntentStatus.PROCESSING, StripeIntentStatus.UNKNOWN): 

167 # either still waiting for unknown or waiting for stripe. 

168 if IS_DEBUG: 

169 msg = f"Pledge {self._dbg_msg} needs refresh because intent status is {intent_status}." 

170 LOG().debug(msg) 

171 self._state |= PaymentState.NEEDS_REFRESH 

172 return 

173 

174 if dispute_id is not None: 

175 if IS_DEBUG: 

176 msg = f"Pledge {self._dbg_msg} needs refresh because dispute_id {dispute_id} is present." 

177 LOG().debug(msg) 

178 self._state |= PaymentState.NEEDS_REFRESH 

179 return 

180 

181 if capture_before is None: 

182 if IS_DEBUG: 

183 msg = f"Pledge {self._dbg_msg} needs refresh because capture_before is missing." 

184 LOG().debug(msg) 

185 self._state |= PaymentState.NEEDS_REFRESH 

186 return 

187 

188 if IS_DEBUG: 

189 msg = ( 

190 f"Pledge {self._dbg_msg} (intent={intent_status}, dispute_id={dispute_id}, " 

191 f"capture_before={capture_before}) DOES NOT need refresh." 

192 ) 

193 LOG().debug(msg) 

194 

195 def _check_consistent(self) -> None: 

196 payment = self._payment 

197 intent_status = payment.intent_status 

198 payment_status = payment.status 

199 

200 if intent_status is None: 

201 return 

202 

203 updated_status: PaymentStatus | None = None 

204 

205 if ( 

206 intent_status == StripeIntentStatus.CANCELED 

207 and payment_status != PaymentStatus.COMPLETE 

208 ): 

209 msg = f"Pledge {self._dbg_msg} INCONSISTENT: intent=CANCELED, status={payment_status}, expected COMPLETE." 

210 self._add_warning(msg) 

211 updated_status = PaymentStatus.COMPLETE 

212 elif ( 

213 intent_status == StripeIntentStatus.SUCCEEDED 

214 and payment_status != PaymentStatus.TRANSFER 

215 ): 

216 msg = f"Pledge {self._dbg_msg} INCONSISTENT: intent=SUCCEEDED, status={payment_status}, expected RELEASE." 

217 self._add_warning(msg) 

218 updated_status = PaymentStatus.TRANSFER 

219 elif intent_status.requires_additional_info and payment_status != PaymentStatus.WAITING: 

220 msg = f"Pledge {self._dbg_msg} INCONSISTENT: intent={intent_status}, status={payment_status}, expected WAITING." 

221 self._add_warning(msg) 

222 updated_status = PaymentStatus.WAITING 

223 elif ( 

224 intent_status == StripeIntentStatus.REQUIRES_CAPTURE 

225 and payment_status != PaymentStatus.CAPTURE 

226 ): 

227 msg = f"Pledge {self._dbg_msg} INCONSISTENT: intent=REQUIRES_CAPTURE, status={payment_status}, expected CAPTURE." 

228 self._add_warning(msg) 

229 updated_status = PaymentStatus.CAPTURE 

230 

231 if updated_status is None: 

232 if IS_DEBUG: 

233 msg = f"Pledge {self._dbg_msg} is consistent (intent={intent_status}, status={payment_status})." 

234 LOG().debug(msg) 

235 return 

236 

237 if IS_DEBUG: 

238 msg = f"Pledge {self._dbg_msg} has inconsistent state, updating payment status to {updated_status}." 

239 LOG().debug(msg) 

240 

241 self._update_pledge(payment_status=updated_status) 

242 self._state |= PaymentState.INCONSISTENT 

243 

244 def _check_can_reauthorize(self) -> None: 

245 payment = self._payment 

246 

247 intent_status = payment.intent_status 

248 payment_status = payment.status 

249 

250 amount = payment.amount 

251 amount_captured = payment.amount_captured or 0 

252 

253 if intent_status is None: 

254 msg = f"Cannot reauthorize {self._dbg_msg} because intent status is missing." 

255 self._add_error(AppPaymentErrorCode.PAYMENT_MISSING, msg) 

256 self._state |= PaymentState.IS_ERROR 

257 return 

258 

259 if intent_status.is_completed: 

260 msg = f"Cannot reauthorize {self._dbg_msg} because intent status {intent_status} is already completed." 

261 if IS_DEBUG: 

262 LOG().debug(msg) 

263 

264 if intent_status == StripeIntentStatus.SUCCEEDED: 

265 if IS_DEBUG: 

266 msg = f"Pledge {self._dbg_msg} has succeeded, changing status to TRANSFER." 

267 LOG().debug(msg) 

268 

269 self._update_pledge(payment_status=PaymentStatus.TRANSFER) 

270 elif intent_status == StripeIntentStatus.CANCELED: 

271 if IS_DEBUG: 

272 msg = f"Pledge {self._dbg_msg} has been canceled, changing status to COMPLETE." 

273 LOG().debug(msg) 

274 

275 self._update_pledge(payment_status=PaymentStatus.COMPLETE) 

276 

277 return 

278 

279 if not payment_status.should_reauthorize: 

280 # only reauthorize charges that are in REAUTHORIZE or CAPTURE states. 

281 msg = f"Pledge {self._dbg_msg} is in payment status {payment_status}, which does not require reauthorization." 

282 self._add_warning(msg) 

283 return 

284 

285 if amount_captured >= amount: 

286 if IS_DEBUG: 

287 msg = ( 

288 f"Pledge {self._dbg_msg} has amount_captured " 

289 f"{amount_captured} >= amount {amount}, no need to reauthorize, changing status to RELEASE." 

290 ) 

291 LOG().debug(msg) 

292 

293 self._update_pledge(payment_status=PaymentStatus.TRANSFER) 

294 return 

295 

296 if IS_DEBUG: 

297 msg = ( 

298 f"Pledge {self._dbg_msg} needs reauthorization (intent_status={intent_status}, " 

299 f"payment_status={payment_status}, amount={amount}, amount_captured={amount_captured})" 

300 ) 

301 LOG().debug(msg) 

302 

303 self._state |= PaymentState.CAN_REAUTHORIZE 

304 

305 def _check_needs_capture(self) -> None: 

306 # we only capture if the intent status is requires_capture, and the payment status is capture. 

307 payment = self._payment 

308 intent_status = payment.intent_status 

309 payment_status = payment.status 

310 

311 if intent_status is None: 

312 msg = f"Cannot check capture for {self._dbg_msg} because intent status is missing." 

313 self._add_error(AppPaymentErrorCode.PAYMENT_MISSING, msg) 

314 self._state |= PaymentState.IS_ERROR 

315 return 

316 

317 if intent_status != StripeIntentStatus.REQUIRES_CAPTURE: 

318 if IS_DEBUG: 

319 msg = f"Pledge {self._dbg_msg} does not need capture because intent status is {intent_status}." 

320 LOG().debug(msg) 

321 return 

322 

323 if payment_status == PaymentStatus.CAPTURE: 

324 if IS_DEBUG: 

325 msg = f"Pledge {self._dbg_msg} already has payment status CAPTURE." 

326 LOG().debug(msg) 

327 self._state |= PaymentState.NEEDS_CAPTURE 

328 return 

329 

330 # intent_status is authoritative — state is inconsistent, fix it 

331 self._update_pledge(payment_status=PaymentStatus.CAPTURE) 

332 if IS_DEBUG: 

333 msg = ( 

334 f"Pledge {self._dbg_msg} has insconsistent " 

335 f"state(intent_status={intent_status}, payment_status={payment_status}), " 

336 "updating payment status to CAPTURE." 

337 ) 

338 LOG().debug(msg) 

339 

340 self._state |= PaymentState.NEEDS_CAPTURE 

341 

342 def _check_needs_transfer(self) -> None: 

343 payment = self._payment 

344 intent_status = payment.intent_status 

345 payment_status = payment.status 

346 

347 if intent_status is None: 

348 msg = f"Cannot check transfer for {self._dbg_msg} because intent status is missing." 

349 self._add_error(AppPaymentErrorCode.PAYMENT_MISSING, msg) 

350 self._state |= PaymentState.IS_ERROR 

351 return 

352 

353 charge_id = payment.latest_charge_id 

354 if charge_id is None: 

355 msg = f"Cannot transfer {self._dbg_msg} because there is no captured charge_id yet." 

356 self._add_error(AppPaymentErrorCode.PAYMENT_MISSING, msg) 

357 self._state |= PaymentState.IS_ERROR 

358 return 

359 

360 if intent_status.is_completed: 

361 if ( 

362 intent_status == StripeIntentStatus.CANCELED 

363 and payment_status != PaymentStatus.COMPLETE 

364 ): 

365 self._update_pledge(payment_status=PaymentStatus.COMPLETE) 

366 if IS_DEBUG: 

367 msg = f"Pledge {self._dbg_msg} has been canceled, changing status to COMPLETE." 

368 LOG().debug(msg) 

369 

370 return 

371 

372 if ( 

373 intent_status == StripeIntentStatus.SUCCEEDED 

374 and payment_status != PaymentStatus.TRANSFER 

375 ): 

376 self._update_pledge(payment_status=PaymentStatus.TRANSFER) 

377 if IS_DEBUG: 

378 msg = f"Pledge {self._dbg_msg} has succeeded, changing status to TRANSFER." 

379 LOG().debug(msg) 

380 

381 self._state |= PaymentState.NEEDS_TRANSFER 

382 return 

383 

384 if payment_status != PaymentStatus.TRANSFER: 

385 msg = f"Pledge {self._dbg_msg} does not require transfer because payment status is {payment_status}." 

386 if IS_DEBUG: 

387 LOG().debug(msg) 

388 return 

389 

390 # if we get here we payment_status == Transfer, but the intent_status is not complete 

391 # check if we can reset the payment status 

392 if intent_status == StripeIntentStatus.REQUIRES_CAPTURE: 

393 msg = f"Pledge {self._dbg_msg}, resetting payment status to CAPTURE because intent status is REQUIRES_CAPTURE." 

394 self._add_warning(msg) 

395 self._update_pledge(payment_status=PaymentStatus.CAPTURE) 

396 elif intent_status.requires_additional_info: 

397 msg = f"Pledge {self._dbg_msg}, resetting payment status to WAITING because intent status is {intent_status}." 

398 self._add_warning(msg) 

399 self._update_pledge(payment_status=PaymentStatus.WAITING) 

400 else: 

401 # the only state left is UNKNOWN. 

402 msg = f"Pledge {self._dbg_msg}, resetting payment status to PENDING because intent status is {intent_status}." 

403 self._add_warning(msg) 

404 self._update_pledge(payment_status=PaymentStatus.PENDING) 

405 

406 return 

407 

408 def _check_needs_refund(self) -> None: 

409 payment = self._payment 

410 intent_status = payment.intent_status 

411 payment_status = payment.status 

412 

413 if intent_status is None: 

414 msg = f"Cannot check refund for {self._dbg_msg} because intent status is missing." 

415 self._add_error(AppPaymentErrorCode.PAYMENT_MISSING, msg) 

416 self._state |= PaymentState.IS_ERROR 

417 return 

418 

419 if intent_status.is_completed: 

420 if ( 

421 intent_status == StripeIntentStatus.CANCELED 

422 and payment_status != PaymentStatus.COMPLETE 

423 ): 

424 self._update_pledge(payment_status=PaymentStatus.COMPLETE) 

425 if IS_DEBUG: 

426 msg = f"Pledge {self._dbg_msg} has been canceled, changing status to COMPLETE." 

427 LOG().debug(msg) 

428 

429 return 

430 

431 if ( 

432 intent_status == StripeIntentStatus.SUCCEEDED 

433 and payment_status != PaymentStatus.TRANSFER 

434 ): 

435 self._update_pledge(payment_status=PaymentStatus.TRANSFER) 

436 if IS_DEBUG: 

437 msg = f"Pledge {self._dbg_msg} has succeeded, changing status to TRANSFER." 

438 LOG().debug(msg) 

439 

440 return 

441 

442 if payment_status != PaymentStatus.REFUND: 

443 msg = f"Pledge {self._dbg_msg} does not require refund because payment status is {payment_status}." 

444 if IS_DEBUG: 

445 LOG().debug(msg) 

446 return 

447 

448 # if we get here we payment_status == REFUND, but the intent_status is not complete 

449 # check if we can reset the payment status 

450 if intent_status == StripeIntentStatus.REQUIRES_CAPTURE: 

451 msg = f"Pledge {self._dbg_msg}, resetting payment status to CAPTURE because intent status is REQUIRES_CAPTURE." 

452 self._add_warning(msg) 

453 self._update_pledge(payment_status=PaymentStatus.CAPTURE) 

454 elif intent_status.requires_additional_info: 

455 msg = f"Pledge {self._dbg_msg}, resetting payment status to WAITING because intent status is {intent_status}." 

456 self._add_warning(msg) 

457 self._update_pledge(payment_status=PaymentStatus.WAITING) 

458 else: 

459 # the only state left is UNKNOWN. 

460 msg = f"Pledge {self._dbg_msg}, resetting payment status to PENDING because intent status is {intent_status}." 

461 self._add_warning(msg) 

462 self._update_pledge(payment_status=PaymentStatus.PENDING) 

463 

464 if IS_DEBUG: 

465 msg = f"Pledge {self._dbg_msg} needs refund (intent_status={intent_status}, payment_status={payment_status})." 

466 LOG().debug(msg) 

467 self._state |= PaymentState.NEEDS_REFUND 

468 

469 def _check_needs_additional_info(self) -> None: 

470 payment = self._payment 

471 intent_status = payment.intent_status 

472 payment_status = payment.status 

473 

474 if intent_status is None: 

475 msg = f"Cannot check user intervention for {self._dbg_msg} because intent status is missing." 

476 self._add_error(AppPaymentErrorCode.PAYMENT_MISSING, msg) 

477 self._state |= PaymentState.IS_ERROR 

478 return 

479 

480 if not intent_status.requires_additional_info: 

481 return 

482 

483 if payment_status == PaymentStatus.WAITING: 

484 return 

485 

486 # if we get here, we need to change the payment status to waiting and ask for additional info. 

487 self._update_pledge(payment_status=PaymentStatus.WAITING) 

488 msg = f"Pledge {self._dbg_msg} requires additional info from user, changing payment status to WAITING." 

489 self._add_warning(msg) 

490 

491 self._state |= PaymentState.NEEDS_ADDITIONAL_INFO 

492 

493 # ======================================================================== 

494 # HELPERS 

495 # ======================================================================== 

496 

497 def _add_error(self, code: AppPaymentErrorCode, message: str) -> None: 

498 LOG().error(f"Pledge {self._pledge.doc_id}: {code.value} - {message}") 

499 

500 if code == AppPaymentErrorCode.PAYMENT_MISSING: # noqa: SIM102 

501 # check we dont have duplicate 

502 if any(existing_code == code for existing_code, _ in self._errors): 

503 if IS_DEBUG: 

504 msg = f"Duplicate error code {code.value} for pledge {self._pledge.doc_id}, skipping." 

505 LOG().debug(msg) 

506 return 

507 

508 self._errors.append((code, message)) 

509 

510 def _add_warning(self, message: str) -> None: 

511 LOG().warning(f"Pledge {self._pledge.doc_id}: {message}") 

512 self._warnings.append(message) 

513 

514 def _update_pledge( 

515 self, 

516 *, 

517 payment_status: PaymentStatus | None = None, 

518 ) -> None: 

519 needs_update: bool = False 

520 

521 if payment_status is not None and self._payment.status is not payment_status: 

522 needs_update = True 

523 

524 if not needs_update: 

525 return 

526 

527 updated_pledge = self._updated_pledge 

528 if updated_pledge is None: 

529 updated_pledge = PledgeWrapper.from_model(self._pledge._model) 

530 

531 # narrowing, payment must exist when PaymentValidator is created. 

532 assert updated_pledge.payment is not None 

533 

534 if payment_status is not None: 

535 updated_pledge.payment.status = payment_status 

536 

537 self._updated_pledge = updated_pledge