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

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# 

12 

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 

27 

28__all__ = ["AccountDTO", "AccountDTOFactory"] 

29 

30 

31class AccountDTO: 

32 """ 

33 # account data transfer object. 

34 NOTE: The stripe api is dog shit. 

35 """ 

36 

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 ) 

56 

57 _acct: StripeV1Account | StripeV2Account 

58 _acct_id: str 

59 _account_type: StripeAccountType 

60 

61 _name: str | None 

62 _email: str | None 

63 _country: str | None 

64 _currency: str | None 

65 

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 

75 

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 

115 

116 @property 

117 def name(self) -> str | None: 

118 return self._name 

119 

120 @property 

121 def account_id(self) -> str: 

122 return self._acct_id 

123 

124 @property 

125 def livemode(self) -> bool: 

126 return self._live_mode 

127 

128 @property 

129 def account_type(self) -> StripeAccountType: 

130 return self._account_type 

131 

132 @property 

133 def email(self) -> str | None: 

134 return self._email 

135 

136 @property 

137 def country(self) -> StripeCountryCode | None: 

138 return StripeCountryCode(self._country.upper()) if self._country is not None else None 

139 

140 @property 

141 def currency(self) -> StripeCurrencyCode | None: 

142 return StripeCurrencyCode(self._currency.upper()) if self._currency is not None else None 

143 

144 @property 

145 def disabled(self) -> bool: 

146 return self._disabled 

147 

148 @property 

149 def closed(self) -> bool: 

150 return self._closed 

151 

152 @property 

153 def disabled_reason(self) -> str | None: 

154 return self._disabled_reason 

155 

156 @property 

157 def details_submitted(self) -> bool: 

158 return self._details_submitted 

159 

160 @property 

161 def transfers_enabled(self) -> bool: 

162 return self._transfers_enabled 

163 

164 @property 

165 def charges_enabled(self) -> bool: 

166 return self._charges_enabled 

167 

168 @property 

169 def payouts_enabled(self) -> bool: 

170 return self._payouts_enabled 

171 

172 @property 

173 def mcc(self) -> str | None: 

174 return self._mcc 

175 

176 @property 

177 def account_ready(self) -> bool: 

178 if self._onboard_result is None: 

179 raise ValueError("Onboard status is not set") 

180 

181 if self._onboard_result != StripeOnboardResult.SUBMITTED or self._closed or self._disabled: 

182 return False 

183 

184 return self._charges_enabled and self._payouts_enabled and self._details_submitted 

185 

186 @property 

187 def onboard_result(self) -> StripeOnboardResult: 

188 if self._onboard_result is None: 

189 raise ValueError("Onboard status is not set") 

190 

191 return self._onboard_result 

192 

193 def is_data_valid(self) -> bool: 

194 return self._email is not None and self._onboard_result is not None 

195 

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() 

215 

216 

217class AccountDTOFactory: 

218 def __init__(self, acct: StripeV1Account | StripeV2Account) -> None: 

219 self._acct = acct 

220 

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) 

227 

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)}") 

231 

232 acct_id = acct.id 

233 

234 safe = SafeStripeObject(acct) 

235 name = safe.display_name.unwrap() 

236 email = safe.contact_email.unwrap() 

237 livemode = safe.livemode.unwrap() 

238 

239 transfers_enabled = False 

240 country: str | None = None 

241 currency: str | None = None 

242 account_type: StripeAccountType | None = None 

243 

244 LOG().debug(f"Parsing V2 account {acct_id} with display name {name}") 

245 

246 results: list[StripeOnboardResult] = [] 

247 parse_msg: list[str] = [] 

248 

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 

257 

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 ) 

270 

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 

276 

277 assert account_type is not None # narrowing 

278 

279 identity = safe.identity 

280 if identity: 

281 country = identity.country.unwrap() 

282 

283 merchant = safe.configuration.merchant 

284 mcc = merchant.mcc.unwrap() 

285 

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 ) 

290 

291 details_submitted = False 

292 payouts_enabled = False 

293 charges_enabled = False 

294 transfers_enabled = False 

295 

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) 

302 

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 

308 

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 

316 

317 if safe.defaults: 

318 currency = safe.defaults.currency.unwrap() 

319 

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) 

326 

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 

339 

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}") 

343 

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 ) 

362 

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 

367 

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 

382 

383 livemode: bool = safe.livemode.unwrap() 

384 profile = safe.business_profile 

385 if profile: 

386 name = profile.name.unwrap() 

387 mcc = profile.mcc.unwrap() 

388 

389 type_str = safe.type.unwrap() 

390 is_express: bool = True 

391 

392 if type_str is None: 

393 # we try the fallback which 

394 type_str = safe.controller.stripe_dashboard.type.unwrap() 

395 

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 

403 

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 ) 

415 

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 ) 

421 

422 assert account_type is not None # narrowing 

423 

424 email = safe.email.unwrap() 

425 country = safe.country.unwrap() 

426 currency = safe.default_currency.unwrap() 

427 details_submitted = bool(safe.details_submitted) 

428 

429 charges_enabled = False 

430 payouts_enabled = False 

431 transfers_enabled = False 

432 

433 if IS_DEBUG: 

434 msg = f"Parsing V1 account {acct_id} with type {type_str}, is_express={is_express}" 

435 LOG().debug(msg) 

436 

437 results: list[StripeOnboardResult] = [] 

438 parse_msg: list[str] = [] 

439 

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) 

444 

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}") 

448 

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) 

454 

455 is_payouts_enabled = safe.payouts_enabled.unwrap() 

456 parse_msg.append(f"Account {acct_id} payouts enabled: {is_payouts_enabled}") 

457 

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) 

463 

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) 

472 

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) 

477 

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 

482 

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) 

491 

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) 

508 

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) 

513 

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}") 

518 

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) 

538 

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 

550 

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}") 

554 

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 )