Coverage for functions \ flipdare \ payments \ dto \ account_dto.py: 85%
198 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#
13from __future__ import annotations
14from warnings import deprecated
15from stripe import Account as StripeV1Account
16from stripe.v2.core import Account as StripeV2Account
17from flipdare.app_log import LOG
18from flipdare.constants import IS_DEBUG, IS_TRACE
19from flipdare.error.app_stripe_error import AppStripeError
20from flipdare.generated.shared.stripe.stripe_account_type import StripeAccountType
21from flipdare.generated.shared.stripe.stripe_country_code import StripeCountryCode
22from flipdare.generated.shared.stripe.stripe_currency_code import StripeCurrencyCode
23from flipdare.generated.shared.stripe.stripe_onboard_result import StripeOnboardResult
24from flipdare.message.user_error_code import UserStripeErrorCode
25from flipdare.payments.dto.safe_stripe_object import SafeStripeObject
26from flipdare.util.debug_util import stringify_debug
28__all__ = ["AccountDTO", "AccountDTOFactory"]
31class AccountDTO:
32 """
33 # account data transfer object.
34 NOTE: The stripe api is dog shit.
35 """
37 __slots__ = (
38 "_account_type",
39 "_acct",
40 "_acct_id",
41 "_charges_enabled",
42 "_closed",
43 "_country",
44 "_currency",
45 "_details_submitted",
46 "_disabled",
47 "_disabled_reason",
48 "_email",
49 "_live_mode",
50 "_mcc",
51 "_name",
52 "_onboard_result",
53 "_payouts_enabled",
54 "_transfers_enabled",
55 )
57 _acct: StripeV1Account | StripeV2Account
58 _acct_id: str
59 _account_type: StripeAccountType
61 _name: str | None
62 _email: str | None
63 _country: str | None
64 _currency: str | None
66 _mcc: str | None
67 _details_submitted: bool
68 _payouts_enabled: bool
69 _charges_enabled: bool
70 _transfers_enabled: bool
71 _onboard_result: StripeOnboardResult | None
72 _disabled: bool
73 _disabled_reason: str | None
74 _closed: bool
76 def __init__(
77 self,
78 acct: StripeV1Account | StripeV2Account,
79 acct_id: str,
80 account_type: StripeAccountType,
81 live_mode: bool,
82 mcc: str | None = None,
83 name: str | None = None,
84 email: str | None = None,
85 country: str | None = None,
86 currency: str | None = None,
87 disabled: bool = False,
88 closed: bool = False,
89 details_submitted: bool = False,
90 payouts_enabled: bool = False,
91 charges_enabled: bool = False,
92 transfers_enabled: bool = False,
93 onboard_result: StripeOnboardResult | None = None,
94 disabled_reason: str | None = None,
95 ) -> None:
96 self._acct = acct
97 self._live_mode = live_mode
98 # these throw value errors if none
99 self._acct_id = acct_id
100 self._onboard_result = onboard_result
101 # the rest
102 self._name = name
103 self._mcc = mcc
104 self._email = email
105 self._country = country
106 self._currency = currency
107 self._details_submitted = details_submitted
108 self._closed = closed
109 self._disabled = disabled
110 self._disabled_reason = disabled_reason
111 self._charges_enabled = charges_enabled
112 self._payouts_enabled = payouts_enabled
113 self._transfers_enabled = transfers_enabled
114 self._account_type = account_type
116 @property
117 def name(self) -> str | None:
118 return self._name
120 @property
121 def account_id(self) -> str:
122 return self._acct_id
124 @property
125 def livemode(self) -> bool:
126 return self._live_mode
128 @property
129 def account_type(self) -> StripeAccountType:
130 return self._account_type
132 @property
133 def email(self) -> str | None:
134 return self._email
136 @property
137 def country(self) -> StripeCountryCode | None:
138 return StripeCountryCode(self._country.upper()) if self._country is not None else None
140 @property
141 def currency(self) -> StripeCurrencyCode | None:
142 return StripeCurrencyCode(self._currency.upper()) if self._currency is not None else None
144 @property
145 def disabled(self) -> bool:
146 return self._disabled
148 @property
149 def closed(self) -> bool:
150 return self._closed
152 @property
153 def disabled_reason(self) -> str | None:
154 return self._disabled_reason
156 @property
157 def details_submitted(self) -> bool:
158 return self._details_submitted
160 @property
161 def transfers_enabled(self) -> bool:
162 return self._transfers_enabled
164 @property
165 def charges_enabled(self) -> bool:
166 return self._charges_enabled
168 @property
169 def payouts_enabled(self) -> bool:
170 return self._payouts_enabled
172 @property
173 def mcc(self) -> str | None:
174 return self._mcc
176 @property
177 def account_ready(self) -> bool:
178 if self._onboard_result is None:
179 raise ValueError("Onboard status is not set")
181 if self._onboard_result != StripeOnboardResult.SUBMITTED or self._closed or self._disabled:
182 return False
184 return self._charges_enabled and self._payouts_enabled and self._details_submitted
186 @property
187 def onboard_result(self) -> StripeOnboardResult:
188 if self._onboard_result is None:
189 raise ValueError("Onboard status is not set")
191 return self._onboard_result
193 def is_data_valid(self) -> bool:
194 return self._email is not None and self._onboard_result is not None
196 def debug_str(self) -> str:
197 msg = f"""
198 AccountDTO:
199 acct_id = {self._acct_id or "None"}
200 name = {self._name or "None"}
201 email = {self._email or "None"}
202 country = {self._country or "None"}
203 currency = {self._currency or "None"}
204 details_submitted = {self._details_submitted or "None"}
205 charges_enabled = {self._charges_enabled or "None"}
206 payouts_enabled = {self._payouts_enabled or "None"}
207 transfers_enabled = {self._transfers_enabled or "None"}
208 onboard_status = {self._onboard_result or "None"}
209 disabled = {self._disabled or "None"}
210 disabled_reason = {self._disabled_reason or "None"}
211 closed = {self._closed or "None"}
212 account_type = {self._account_type.value if self._account_type else "None"}
213 """
214 return msg.strip()
217class AccountDTOFactory:
218 def __init__(self, acct: StripeV1Account | StripeV2Account) -> None:
219 self._acct = acct
221 def create(self) -> AccountDTO:
222 match self._acct:
223 case StripeV1Account():
224 return self._parse_v1(self._acct) # type: ignore
225 case StripeV2Account():
226 return self._parse_v2(self._acct)
228 def _parse_v2(self, acct: StripeV2Account) -> AccountDTO: # noqa: PLR0912, PLR0915
229 if IS_TRACE:
230 LOG().trace(f"Parsing V2 account {acct.id} with data:\n{stringify_debug(acct)}")
232 acct_id = acct.id
234 safe = SafeStripeObject(acct)
235 name = safe.display_name.unwrap()
236 email = safe.contact_email.unwrap()
237 livemode = safe.livemode.unwrap()
239 transfers_enabled = False
240 country: str | None = None
241 currency: str | None = None
242 account_type: StripeAccountType | None = None
244 LOG().debug(f"Parsing V2 account {acct_id} with display name {name}")
246 results: list[StripeOnboardResult] = []
247 parse_msg: list[str] = []
249 is_closed = safe.closed
250 if is_closed:
251 closed = True
252 disabled = True
253 results.append(StripeOnboardResult.CLOSED)
254 else:
255 closed = False
256 disabled = False
258 dashboard = safe.dashboard
259 if dashboard:
260 dashboard_str = dashboard.unwrap()
261 try:
262 account_type = StripeAccountType.from_string(dashboard_str)
263 except ValueError as err:
264 error_code = UserStripeErrorCode.ACCOUNT_HAS_NO_TYPE
265 cause = (
266 f"Account {acct_id}/{name}/{email} generated the following error code: "
267 f"{error_code.value} because it has no account type set (type is None) "
268 f"and no dashboard type set (dashboard.type is None) or an unknown type ({dashboard_str})"
269 )
271 raise AppStripeError.invalid_data(
272 endpoint=f"AccountDTOFactory._parse_v1 for account {acct_id}",
273 invalid_error_code=error_code,
274 cause=cause,
275 ) from err
277 assert account_type is not None # narrowing
279 identity = safe.identity
280 if identity:
281 country = identity.country.unwrap()
283 merchant = safe.configuration.merchant
284 mcc = merchant.mcc.unwrap()
286 card_payment_status = merchant.capabilities.card_payments.status
287 parse_msg.append(
288 f"Account {acct_id} card payments status: {card_payment_status or 'None'}"
289 )
291 details_submitted = False
292 payouts_enabled = False
293 charges_enabled = False
294 transfers_enabled = False
296 if card_payment_status == "active":
297 charges_enabled = True
298 details_submitted = True
299 elif card_payment_status: # Exists but isn't 'active'
300 parse_msg.append(f"Account {acct_id} card payments inactive ({card_payment_status})")
301 results.append(StripeOnboardResult.CARD_PAYMENTS_NOT_ACTIVE)
303 payout_status = merchant.capabilities.stripe_balance.payouts.status
304 if payout_status:
305 parse_msg.append(f"Account {acct_id} payouts status: {payout_status}")
306 if payout_status == "active":
307 payouts_enabled = True
309 recipient_status = (
310 safe.configuration.recipient.capabilities.stripe_balance.transfers.status
311 )
312 if recipient_status:
313 parse_msg.append(f"Account {acct_id} stripe transfers status: {recipient_status}")
314 if recipient_status == "active":
315 transfers_enabled = True
317 if safe.defaults:
318 currency = safe.defaults.currency.unwrap()
320 # Check for requirements
321 for entry in safe.requirements.entries:
322 if len(entry.errors) > 0:
323 msg = f"Account {acct_id} has requirement entry {entry} with errors: {entry.errors.unwrap()}"
324 parse_msg.append(msg)
325 results.append(StripeOnboardResult.PENDING_REQUIREMENTS)
327 # set the onboard status to the most severe status, or submitted if no issues found
328 if len(results) > 0:
329 # we can determine severity based on the order we added statuses, since they are added in order of severity
330 onboard_result = results[0]
331 parse_msg.append(
332 f"Account {acct_id} onboard status determined to be {results[0]} "
333 f"based on checks, with details: {results[0].label}"
334 )
335 else:
336 parse_msg.append(f"Account {acct_id} has completed onboarding.")
337 onboard_result = StripeOnboardResult.SUBMITTED
338 details_submitted = True
340 if IS_DEBUG:
341 parse_result = "\t" + "\n\t".join(parse_msg)
342 LOG().debug(f"Parse result for account {acct_id}:\n{parse_result}")
344 return AccountDTO(
345 acct=acct,
346 acct_id=acct_id,
347 live_mode=livemode,
348 name=name,
349 mcc=mcc,
350 account_type=account_type,
351 email=email,
352 country=country,
353 currency=currency,
354 details_submitted=details_submitted,
355 charges_enabled=charges_enabled,
356 payouts_enabled=payouts_enabled,
357 transfers_enabled=transfers_enabled,
358 onboard_result=onboard_result,
359 disabled=disabled,
360 closed=closed,
361 )
363 @deprecated("V1 accounts should no longer be used")
364 def _parse_v1(self, acct: StripeV1Account) -> AccountDTO: # noqa: PLR0912, PLR0915
365 safe = SafeStripeObject(acct)
366 acct_id = acct.id
368 name: str | None = None
369 mcc: str | None = None
370 account_type: StripeAccountType | None = None
371 email: str | None = None
372 country: str | None = None
373 currency: str | None = None
374 details_submitted: bool = False
375 charges_enabled: bool = False
376 payouts_enabled: bool = False
377 transfers_enabled: bool = False
378 onboard_result: StripeOnboardResult | None = None
379 disabled: bool = False
380 disabled_reason: str | None = None
381 closed: bool = False
383 livemode: bool = safe.livemode.unwrap()
384 profile = safe.business_profile
385 if profile:
386 name = profile.name.unwrap()
387 mcc = profile.mcc.unwrap()
389 type_str = safe.type.unwrap()
390 is_express: bool = True
392 if type_str is None:
393 # we try the fallback which
394 type_str = safe.controller.stripe_dashboard.type.unwrap()
396 invalid_error_code: UserStripeErrorCode | None = None
397 if type_str is not None:
398 try:
399 account_type = StripeAccountType(type_str)
400 is_express = account_type == StripeAccountType.EXPRESS
401 except ValueError:
402 invalid_error_code = UserStripeErrorCode.ACCOUNT_NOT_SUPPORTED
404 if type_str is None:
405 # this is not possible, you cant create a stripe account without setting
406 # either the account type or the dashboard type,
407 # since account_type is critical we cannot continue.
408 if invalid_error_code is None:
409 invalid_error_code = UserStripeErrorCode.ACCOUNT_HAS_NO_TYPE
410 cause = (
411 f"Account {acct_id}/{name}/{email} generated the following error code: "
412 f"{invalid_error_code.value} because it has no account type set (type is None) "
413 f"and no dashboard type set (dashboard.type is None) or an unknown type ({type_str})"
414 )
416 raise AppStripeError.invalid_data(
417 endpoint=f"AccountDTOFactory._parse_v1 for account {acct_id}",
418 invalid_error_code=invalid_error_code,
419 cause=cause,
420 )
422 assert account_type is not None # narrowing
424 email = safe.email.unwrap()
425 country = safe.country.unwrap()
426 currency = safe.default_currency.unwrap()
427 details_submitted = bool(safe.details_submitted)
429 charges_enabled = False
430 payouts_enabled = False
431 transfers_enabled = False
433 if IS_DEBUG:
434 msg = f"Parsing V1 account {acct_id} with type {type_str}, is_express={is_express}"
435 LOG().debug(msg)
437 results: list[StripeOnboardResult] = []
438 parse_msg: list[str] = []
440 # Check if account details have been submitted
441 if not details_submitted:
442 parse_msg.append(f"Account {acct_id} details not submitted")
443 results.append(StripeOnboardResult.DETAILS_NOT_SUBMITTED)
445 # Check if charges are enabled
446 is_charges_enabled = safe.charges_enabled.unwrap()
447 parse_msg.append(f"Account {acct_id} charges enabled: {is_charges_enabled}")
449 if is_charges_enabled:
450 charges_enabled = True
451 else:
452 parse_msg.append(f"Account {acct_id} charges not enabled")
453 results.append(StripeOnboardResult.CHARGES_NOT_ENABLED)
455 is_payouts_enabled = safe.payouts_enabled.unwrap()
456 parse_msg.append(f"Account {acct_id} payouts enabled: {is_payouts_enabled}")
458 if is_payouts_enabled:
459 payouts_enabled = True
460 else:
461 parse_msg.append(f"Account {acct_id} payouts not enabled")
462 results.append(StripeOnboardResult.PAYOUTS_NOT_ENABLED)
464 # Check for capability status
465 capabilities = safe.capabilities
466 if not capabilities:
467 results.append(StripeOnboardResult.NO_CAPABILITIES)
468 else:
469 card_payments_status = capabilities.card_payments.unwrap()
470 msg = f"Account {acct_id} card payments status: {card_payments_status or 'None'}"
471 parse_msg.append(msg)
473 if card_payments_status != "active":
474 msg = f"Account {acct_id} card payments inactive ({card_payments_status})"
475 parse_msg.append(msg)
476 results.append(StripeOnboardResult.CARD_PAYMENTS_NOT_ACTIVE)
478 transfers_status = capabilities.transfers.unwrap()
479 parse_msg.append(f"Account {acct_id} transfers status: {transfers_status or 'None'}")
480 if transfers_status == "active":
481 transfers_enabled = True
483 if is_express and not transfers_enabled:
484 # for express accounts, since fees_collection is Application
485 # we need to enable transfers so we can transfer to connected accounts (
486 # after subtracting our fees) and also so connected accounts can pay out to their bank accounts
487 parse_msg.append(
488 f"Account {acct_id} transfers not enabled ({transfers_status or 'None'})"
489 )
490 results.append(StripeOnboardResult.TRANSFERS_NOT_ENABLED)
492 # Check for any pending requirements
493 requirements = safe.requirements
494 if not requirements:
495 # this is an error, requirements should always exist
496 # but are empty if nothing is due
497 parse_msg.append(
498 f"Account {acct_id} has no requirements object, treating as no requirements"
499 )
500 results.append(StripeOnboardResult.NO_REQUIREMENTS)
501 else:
502 currently_due = requirements.currently_due.unwrap()
503 if currently_due is not None and len(currently_due) > 0:
504 parse_msg.append(
505 f"Account {acct_id} has currently due requirements: {currently_due}"
506 )
507 results.append(StripeOnboardResult.PENDING_REQUIREMENTS)
509 past_due = requirements.past_due.unwrap()
510 if past_due is not None and len(past_due) > 0:
511 parse_msg.append(f"Account {acct_id} has past due requirements: {past_due}")
512 results.append(StripeOnboardResult.PAST_DUE_REQUIREMENTS)
514 disabled_result = requirements.disabled_reason
515 if disabled_result:
516 disabled_reason = disabled_result.unwrap()
517 parse_msg.append(f"Account {acct_id} is disabled due to {disabled_reason}")
519 if disabled_reason in [
520 "rejected.fraud",
521 "rejected.listed",
522 "rejected.other",
523 "rejected.platform_fraud",
524 "rejected.platform_other",
525 "rejected.platform_terms_of_service",
526 "rejected.terms_of_service",
527 ]:
528 # Account is effectively closed/rejected
529 closed = True
530 disabled = True
531 # this is more important so insert status at the front of the list
532 results.insert(0, StripeOnboardResult.CLOSED)
533 else:
534 # temporarily disabled.
535 closed = False
536 disabled = True
537 results.append(StripeOnboardResult.DISABLED)
539 # set the onboard status to the most severe status, or submitted if no issues found
540 if len(results) > 0:
541 # we can determine severity based on the order we added statuses, since they are added in order of severity
542 onboard_result = results[0]
543 parse_msg.append(
544 f"Account {acct_id} onboard status determined to be {results[0]} based on "
545 f"checks, with details: {results[0].label}"
546 )
547 else:
548 parse_msg.append(f"Account {acct_id} has no onboard issues, treating as submitted")
549 onboard_result = StripeOnboardResult.SUBMITTED
551 if IS_DEBUG:
552 parse_result = "\t" + "\n\t".join(parse_msg)
553 LOG().debug(f"Parse result for account {acct_id}:\n{parse_result}")
555 return AccountDTO(
556 acct=acct,
557 acct_id=acct_id,
558 live_mode=livemode,
559 name=name,
560 mcc=mcc,
561 account_type=account_type,
562 email=email,
563 country=country,
564 currency=currency,
565 details_submitted=details_submitted,
566 charges_enabled=charges_enabled,
567 payouts_enabled=payouts_enabled,
568 onboard_result=onboard_result,
569 disabled=disabled,
570 disabled_reason=disabled_reason,
571 closed=closed,
572 )