Coverage for functions \ flipdare \ payments \ dto \ charge_dto.py: 83%
154 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
1from __future__ import annotations
2from datetime import datetime
3from typing import Self
4from stripe import Charge as StripeCharge
5from stripe import BalanceTransaction as StripeBalanceTransaction
7from flipdare.app_log import LOG
8from flipdare.error.app_stripe_error import AppStripeError
9from flipdare.message.user_error_code import UserStripeErrorCode
10from flipdare.payments.dto.safe_stripe_object import SafeStripeObject
11from flipdare.util.time_util import TimeUtil
12from flipdare.constants import DEFAULT_REAUTHORIZATION_DAYS_CUSTOMER
15class ChargeDTO:
16 __slots__ = (
17 "_amount",
18 "_amount_captured",
19 "_amount_refunded",
20 "_app_fee_amount",
21 "_available_on",
22 "_balance_id",
23 "_balance_status",
24 "_capture_before",
25 "_captured",
26 "_charge_id",
27 "_extended_auth",
28 "_extended_auth_status",
29 "_stripe_error_code",
30 "_stripe_error_message",
31 "_stripe_fee_amount",
32 )
34 _charge_id: str
35 _balance_id: str | None
36 _balance_status: str | None
37 _available_on: int | None
38 _captured: bool
40 _stripe_error_code: str | None
41 _stripe_error_message: str | None
43 _amount: int | None
44 _amount_captured: int | None
45 _amount_refunded: int | None
47 _stripe_fee_amount: int | None
48 _app_fee_amount: int | None
50 _extended_auth: bool
51 _extended_auth_status: str | None
52 _capture_before: datetime
54 def __init__(
55 self,
56 charge_id: str,
57 captured: bool,
58 capture_before: datetime,
59 amount: int,
60 amount_captured: int,
61 amount_refunded: int,
62 stripe_error_code: str | None = None,
63 stripe_error_message: str | None = None,
64 stripe_fee_amount: int | None = None,
65 app_fee_amount: int | None = None,
66 extended_auth: bool = False,
67 balance_id: str | None = None,
68 balance_status: str | None = None,
69 available_on: int | None = None,
70 extended_auth_status: str | None = None,
71 ) -> None:
72 self._captured = captured
73 self._charge_id = charge_id
74 self._amount = amount
75 self._amount_captured = amount_captured
76 self._amount_refunded = amount_refunded
77 self._stripe_error_code = stripe_error_code
78 self._stripe_error_message = stripe_error_message
79 self._stripe_fee_amount = stripe_fee_amount
80 self._app_fee_amount = app_fee_amount
81 self._extended_auth = extended_auth
82 self._extended_auth_status = extended_auth_status
83 self._capture_before = capture_before
84 self._balance_id = balance_id
85 self._balance_status = balance_status
86 self._available_on = available_on
88 @classmethod
89 def from_charge(cls, charge: StripeCharge) -> Self: # noqa: PLR0915
90 # NOTE: StripeBalanceTransaction is the authoritative source for:
91 # NOTE: Fee calculations
92 # NOTE: Actual money movement
93 # NOTE: Net amounts received
94 # NOTE: Settlement currency
95 # NOTE: charge.amount_captured is only authoritative for
96 # NOTE: customer-facing amounts and billing records.
97 # this can occur if the charge.currency is different from the settlement currency
98 charge_id = charge.id
100 extended_auth = False
101 extended_auth_status = None
102 capture_before = None
103 balance_status = None
104 available_on = None
106 safe = SafeStripeObject(charge)
108 stripe_error_code = safe.failure_code.unwrap()
109 stripe_error_message = safe.failure_message.unwrap()
111 captured = charge.captured
112 created = charge.created
113 amount = charge.amount
114 amount_captured = charge.amount_captured
115 amount_refunded = charge.amount_refunded
117 # None means the charge has not been captured ..
118 app_fee_amount = safe.application_fee_amount.unwrap() or None
119 # this is returned in the charge.currency
120 stripe_fee_amount = None
121 balance_transaction_id = None
123 balance = safe.balance_transaction
124 if balance:
125 balance_transaction = balance.unwrap()
126 if isinstance(balance_transaction, str):
127 # not expanded, balance is a _balance_transaction_id
128 # this is an error because we cannot accurately calculate fees
129 # or net amounts without the balance transaction details.
130 msg = f"Balance transaction for charge {charge_id} is not expanded. Cant get fee"
131 LOG().error(msg)
132 raise AppStripeError.invalid_data(
133 endpoint="ChargeDTO.from_charge",
134 invalid_error_code=UserStripeErrorCode.BALANCE_NOT_PRESENT,
135 cause=msg,
136 )
138 assert isinstance(balance_transaction, StripeBalanceTransaction)
139 # expanded, balance is a StripeBalanceTransaction object
140 balance_transaction_id = balance_transaction.id
141 balance_status = balance_transaction.status
142 available_on = balance_transaction.available_on
144 stripe_fee_amount = balance_transaction.fee
145 bal_amount = balance_transaction.amount
146 if amount_captured and bal_amount != amount_captured:
147 # this is a sanity check, if the balance amount doesn't match the charge amount, we log it.
148 # this should never happen, but if it does, it's important to know about it.
149 # NOTE: we use the charge amount captured, not the balance amount,
150 msg = f"Balance amount {bal_amount} does not match captured amount {amount_captured} for charge {charge_id}"
151 LOG().warning(msg)
153 # Check if card payment method details exist
154 card_details = safe.payment_method_details.card
155 cap_before: int | None = None
157 if card_details:
158 extend_auth = card_details.extended_authorization.status
159 cap_before = card_details.capture_before.unwrap()
160 LOG().warning(f"Cap before for charge {charge_id} is {cap_before}")
161 if extend_auth:
162 status = extend_auth.unwrap()
163 if status == "enabled":
164 extended_auth = True
165 extended_auth_status = status
166 else:
167 extended_auth_status = status
169 if cap_before is not None:
170 capture_before = TimeUtil.epoch_to_utc_dt(cap_before)
171 else:
172 # add DEFAULT_REAUTHORIZATION_DAYS_CUSTOMER days to created
173 # to get an approximate capture before data
174 # for customer initiated transactions (Which is all we do)
175 created_dt = TimeUtil.epoch_to_utc_dt(created)
176 capture_before = TimeUtil.get_utc_time_future_days(
177 created_dt, DEFAULT_REAUTHORIZATION_DAYS_CUSTOMER
178 )
180 return cls(
181 charge_id=charge_id,
182 captured=captured,
183 stripe_error_code=stripe_error_code,
184 stripe_error_message=stripe_error_message,
185 amount=amount,
186 amount_captured=amount_captured,
187 amount_refunded=amount_refunded,
188 stripe_fee_amount=stripe_fee_amount,
189 app_fee_amount=app_fee_amount,
190 extended_auth=extended_auth,
191 extended_auth_status=extended_auth_status,
192 capture_before=capture_before,
193 balance_id=balance_transaction_id,
194 balance_status=balance_status,
195 available_on=available_on,
196 )
198 @property
199 def charge_id(self) -> str:
200 return self._charge_id
202 @property
203 def balance_id(self) -> str | None:
204 return self._balance_id
206 @property
207 def amount(self) -> int | None:
208 return self._amount
210 @property
211 def amount_captured(self) -> int | None:
212 return self._amount_captured
214 @property
215 def amount_refunded(self) -> int | None:
216 return self._amount_refunded
218 @property
219 def stripe_fee_amount(self) -> int | None:
220 return self._stripe_fee_amount
222 @property
223 def app_fee_amount(self) -> int | None:
224 return self._app_fee_amount
226 @property
227 def extended_auth(self) -> bool:
228 return self._extended_auth
230 @property
231 def extended_auth_status(self) -> str | None:
232 return self._extended_auth_status
234 @property
235 def capture_before(self) -> datetime | None:
236 return self._capture_before
238 @property
239 def stripe_error_code(self) -> str | None:
240 return self._stripe_error_code
242 @property
243 def stripe_error_message(self) -> str | None:
244 return self._stripe_error_message
246 @property
247 def is_error(self) -> bool:
248 return self._stripe_error_code is not None or self._stripe_error_message is not None
250 @property
251 def is_available(self) -> bool:
252 balance_status = self._balance_status
253 if balance_status is None:
254 return False
256 return balance_status == "available"
258 @property
259 def is_pending(self) -> bool:
260 balance_status = self._balance_status
261 if balance_status is None:
262 return False
264 return balance_status == "pending"
266 @property
267 def available_on(self) -> datetime | None:
268 if self._available_on is None:
269 return None
271 return TimeUtil.epoch_to_utc_dt(self._available_on)
273 def debug_str(self) -> str:
274 return (
275 f"ChargeDTO:\n"
276 f"\tcharge_id={self._charge_id}\n"
277 f"\tamount={self._amount}\n"
278 f"\tamount_captured={self._amount_captured}\n"
279 f"\tamount_refunded={self._amount_refunded}\n"
280 f"\tstripe_fee_amount={self._stripe_fee_amount}\n"
281 f"\tapp_fee_amount={self._app_fee_amount}\n"
282 f"\textended_auth={self._extended_auth}\n"
283 f"\textended_auth_status={self._extended_auth_status}\n"
284 f"\tcapture_before={self._capture_before}\n"
285 f"\tstripe_error_code={self._stripe_error_code}\n"
286 f"\tstripe_error_message={self._stripe_error_message}\n"
287 )