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
« 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#
14from typing import Self, final
15from datetime import datetime
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
36@final
37class PaymentIntentDTO:
38 """
39 To determine of the status you need to check "status" and a "last_payment_error":
41 Overview:
43 "canceled"
44 - if cancellation_reason 'abandoned' or 'automatic' the intent expired.
45 - otherwise, the user cancelled the intent.
47 "processing"
48 - stripe is still processing the payment.
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"
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
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
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.
66 "succeeded"
67 - the payment succeeded and funds were captured.
68 """
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 )
86 _intent_id: str
87 _amount: int
88 _to_currency: str
90 _event_status: PaymentEventStatus
91 _intent_status: StripeIntentStatus
92 _requires_action: bool
94 _created: int
95 _client_secret: str | None
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
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
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
143 latest_charge_id = None
144 charge = None
145 card_country = None
146 payment_method_id = None
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())
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)
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)
174 client_secret = safe.client_secret.unwrap()
175 intent_status, charge_status = cls._get_event_status(intent)
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 )
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
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
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
221 intent_error_status = StripeIntentErrorCode.from_intent(intent)
222 if intent_error_status is not None:
223 return status, intent_error_status
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
236 @property
237 def intent_id(self) -> str:
238 return self._intent_id
240 @property
241 def created(self) -> int:
242 return self._created
244 @property
245 def amount(self) -> int:
246 return self._amount
248 @property
249 def intent_status(self) -> StripeIntentStatus:
250 return self._intent_status
252 @property
253 def event_status(self) -> PaymentEventStatus:
254 return self._event_status
256 @property
257 def requires_action(self) -> bool:
258 return self._intent_status == StripeIntentStatus.REQUIRES_ACTION
260 @property
261 def canceled(self) -> bool:
262 return self._intent_status == StripeIntentStatus.CANCELED
264 @property
265 def currency(self) -> str:
266 return self._to_currency
268 @property
269 def latest_charge_id(self) -> str | None:
270 return self._latest_charge_id
272 @property
273 def payment_method_id(self) -> str | None:
274 return self._payment_method_id
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
285 @property
286 def card_country(self) -> str | None:
287 return self._card_country
289 @property
290 def client_secret(self) -> str | None:
291 return self._client_secret
293 # ========================================================================
294 # CHARGE RELATED FIELDS (only available after authorization)
295 # ========================================================================
297 @property
298 def amount_captured(self) -> int | None:
299 charge = self._charge
300 return charge.amount_captured if charge else None
302 @property
303 def app_fee_amount(self) -> int | None:
304 charge = self._charge
305 return charge.app_fee_amount if charge else None
307 @property
308 def stripe_fee_amount(self) -> int | None:
309 charge = self._charge
310 return charge.stripe_fee_amount if charge else None
312 @property
313 def capture_before(self) -> datetime | None:
314 charge = self._charge
315 return charge.capture_before if charge else None
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)
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
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
336 return code, message
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())
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 )