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
« 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
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()
34class PaymentValidator:
35 __slots__ = (
36 "_errors",
37 "_payment",
38 "_pledge",
39 "_state",
40 "_updated_pledge",
41 "_warnings",
42 )
44 _pledge: PledgeWrapper
45 _payment: PaymentModel
46 _updated_pledge: PledgeWrapper | None
47 _errors: list[tuple[AppPaymentErrorCode, str]]
48 _warnings: list[str]
49 _state: PaymentState
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.")
57 self._pledge = pledge
58 self._payment = payment
59 self._state = PaymentState.NONE
60 self._validate()
62 @property
63 def payment(self) -> PaymentModel:
64 return self._payment
66 @property
67 def needs_refresh(self) -> bool:
68 return bool(
69 self._state
70 & (PaymentState.IS_ERROR | PaymentState.INCONSISTENT | PaymentState.NEEDS_REFRESH)
71 )
73 @property
74 def can_reauthorize(self) -> bool:
75 return bool(self._state & PaymentState.CAN_REAUTHORIZE)
77 @property
78 def needs_capture(self) -> bool:
79 return bool(self._state & PaymentState.NEEDS_CAPTURE)
81 @property
82 def needs_transfer(self) -> bool:
83 return bool(self._state & PaymentState.NEEDS_TRANSFER)
85 @property
86 def needs_additional_info(self) -> bool:
87 return bool(self._state & PaymentState.NEEDS_ADDITIONAL_INFO)
89 @property
90 def needs_refund(self) -> bool:
91 return bool(self._state & PaymentState.NEEDS_REFUND)
93 @property
94 def updated(self) -> PledgeWrapper | None:
95 return self._updated_pledge
97 @property
98 def has_errors(self) -> bool:
99 return len(self._errors) > 0 or bool(self._state & PaymentState.IS_ERROR)
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 )
110 def set_refreshed_payment(self, payment: PaymentModel) -> None:
111 self._payment = payment
112 self._validate()
114 # ========================================================================
115 # MAIN ENTRY POINT
116 # ========================================================================
118 def _reset(self) -> None:
119 self._updated_pledge = None
120 self._errors = []
121 self._warnings = []
122 self._state = PaymentState.NONE
124 def _validate(self) -> None:
125 self._reset()
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()
136 # ========================================================================
137 # CHECKS
138 # ========================================================================
140 def _check_needs_refresh(self) -> None:
141 payment = self._payment
142 payment_method_id = payment.payment_method_id
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)
151 self._state |= PaymentState.NEEDS_REFRESH
152 return
154 dispute_id = payment.stripe_dispute_id
155 capture_before = payment.capture_before
156 intent_status = payment.intent_status
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)
163 self._state |= PaymentState.NEEDS_REFRESH
164 return
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
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
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
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)
195 def _check_consistent(self) -> None:
196 payment = self._payment
197 intent_status = payment.intent_status
198 payment_status = payment.status
200 if intent_status is None:
201 return
203 updated_status: PaymentStatus | None = None
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
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
237 if IS_DEBUG:
238 msg = f"Pledge {self._dbg_msg} has inconsistent state, updating payment status to {updated_status}."
239 LOG().debug(msg)
241 self._update_pledge(payment_status=updated_status)
242 self._state |= PaymentState.INCONSISTENT
244 def _check_can_reauthorize(self) -> None:
245 payment = self._payment
247 intent_status = payment.intent_status
248 payment_status = payment.status
250 amount = payment.amount
251 amount_captured = payment.amount_captured or 0
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
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)
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)
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)
275 self._update_pledge(payment_status=PaymentStatus.COMPLETE)
277 return
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
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)
293 self._update_pledge(payment_status=PaymentStatus.TRANSFER)
294 return
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)
303 self._state |= PaymentState.CAN_REAUTHORIZE
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
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
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
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
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)
340 self._state |= PaymentState.NEEDS_CAPTURE
342 def _check_needs_transfer(self) -> None:
343 payment = self._payment
344 intent_status = payment.intent_status
345 payment_status = payment.status
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
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
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)
370 return
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)
381 self._state |= PaymentState.NEEDS_TRANSFER
382 return
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
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)
406 return
408 def _check_needs_refund(self) -> None:
409 payment = self._payment
410 intent_status = payment.intent_status
411 payment_status = payment.status
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
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)
429 return
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)
440 return
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
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)
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
469 def _check_needs_additional_info(self) -> None:
470 payment = self._payment
471 intent_status = payment.intent_status
472 payment_status = payment.status
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
480 if not intent_status.requires_additional_info:
481 return
483 if payment_status == PaymentStatus.WAITING:
484 return
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)
491 self._state |= PaymentState.NEEDS_ADDITIONAL_INFO
493 # ========================================================================
494 # HELPERS
495 # ========================================================================
497 def _add_error(self, code: AppPaymentErrorCode, message: str) -> None:
498 LOG().error(f"Pledge {self._pledge.doc_id}: {code.value} - {message}")
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
508 self._errors.append((code, message))
510 def _add_warning(self, message: str) -> None:
511 LOG().warning(f"Pledge {self._pledge.doc_id}: {message}")
512 self._warnings.append(message)
514 def _update_pledge(
515 self,
516 *,
517 payment_status: PaymentStatus | None = None,
518 ) -> None:
519 needs_update: bool = False
521 if payment_status is not None and self._payment.status is not payment_status:
522 needs_update = True
524 if not needs_update:
525 return
527 updated_pledge = self._updated_pledge
528 if updated_pledge is None:
529 updated_pledge = PledgeWrapper.from_model(self._pledge._model)
531 # narrowing, payment must exist when PaymentValidator is created.
532 assert updated_pledge.payment is not None
534 if payment_status is not None:
535 updated_pledge.payment.status = payment_status
537 self._updated_pledge = updated_pledge