Coverage for functions \ flipdare \ wrapper \ user_wrapper.py: 78%
442 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
15from typing import TypedDict, Unpack
16from flipdare.app_log import LOG
17from flipdare.constants import IS_TRACE
18from flipdare.generated.model.internal.dare_stats_model import DareStatsModel
19from flipdare.generated.model.internal.image_model import ImageModel
20from flipdare.generated.model.internal.location_model import LocationModel
21from flipdare.generated.model.payment.stripe_account_model import StripeAccountModel
22from flipdare.generated.model.payment.stripe_customer_model import StripeCustomerModel
23from flipdare.generated.model.internal.view_stats_model import ViewStatsModel
24from flipdare.generated.model.issue.flag_model import FlagModel
25from flipdare.generated.model.user_model import StripeSettingsType, UserKeys, UserModel
26from flipdare.generated.shared.model.app_visibility import AppVisibility
27from flipdare.generated.shared.model.user.auth_type import AuthType
28from flipdare.generated.shared.model.user.app_fee_type import AppFeeType
29from flipdare.generated.shared.model.user.user_archive_type import UserArchiveType
30from flipdare.generated.shared.model.user.user_cache_type import UserCacheType
31from flipdare.generated.shared.model.user.user_level_type import UserLevelType
32from flipdare.generated.shared.model.user.video_continue_type import VideoContinueType
33from flipdare.generated.shared.stripe.stripe_account_type import StripeAccountType
34from flipdare.generated.shared.stripe.stripe_country_code import StripeCountryCode
35from flipdare.generated.shared.stripe.stripe_currency_code import StripeCurrencyCode
36from flipdare.generated.shared.stripe.stripe_onboard_result import StripeOnboardResult
37from flipdare.generated.shared.stripe.stripe_onboard_state import StripeOnboardState
38from flipdare.payments.core.stripe_guard import StripeGuard
39from flipdare.payments.core.stripe_invoice_prefix import StripeInvoicePrefix
40from flipdare.wrapper._persisted_wrapper import PersistedWrapper
42_K = UserKeys
45class StripeCustomerUpdateDict(TypedDict, total=False):
46 """Fields that can be updated on a StripeCustomerModel (or the customer portion of an Account)."""
48 email: str
49 name: str
50 customer_id: str
51 country_code: StripeCountryCode
52 alt_currency_code: StripeCurrencyCode
53 invoice_prefix: str
56class StripeAccountUpdateDict(TypedDict, total=False):
57 """Optional fields for update_stripe_account beyond the required positional params."""
59 # shared customer fields
60 email: str
61 name: str
62 customer_id: str
63 country_code: StripeCountryCode
64 invoice_prefix: str
65 # account-only fields
66 onboard_state: StripeOnboardState
67 onboard_result: StripeOnboardResult | None
68 account_link: str | None
69 account_link_expires: float | None
70 stripe_disabled: bool
71 stripe_disabled_reason: str | None
72 stripe_closed: bool
73 highest_transaction_amount: int
74 transaction_volume: int
75 dispute_for_ct: int
76 refund_for_ct: int
77 payout_for_ct: int
78 dispute_against_ct: int
79 refund_against_ct: int
80 payout_against_ct: int
83class UserWrapper(PersistedWrapper[UserModel]):
84 MODEL_CLASS = UserModel
86 def validate_can_create_dare(self) -> tuple[bool, str | None]:
87 """
88 Check if user can create a dare.
90 Returns:
91 Tuple of (can_create, error_message)
93 """
94 if self.flagged is not None:
95 return False, "User is flagged and cannot create dares"
97 if self.visibility != AppVisibility.PUBLIC:
98 return False, "User must have public visibility to create dares"
100 if not self.email_verified:
101 return False, "Email must be verified to create dares"
103 return True, None
105 @property
106 def views(self) -> int:
107 return self.view_stats.views
109 @property
110 def flags(self) -> int:
111 return self.view_stats.flags
113 @property
114 def bookmarks(self) -> int:
115 return self.view_stats.bookmarks
117 @property
118 def processing_complete(self) -> bool:
119 return self.processed and self.context_created and self.search_indexed
121 def reindex(self) -> None:
122 """Force reindexing of user in search."""
123 self.search_indexed = False
125 def create_stripe_customer(
126 self,
127 customer_id: str,
128 email: str,
129 country_code: StripeCountryCode,
130 currency_code: StripeCurrencyCode | None = None,
131 name: str | None = None,
132 first_name: str | None = None,
133 last_name: str | None = None,
134 ) -> None:
135 uid = self.doc_id
136 settings = self.stripe_settings
137 if StripeGuard.is_account(settings):
138 msg = f"User {uid} already has stripe account settings, updating customer fields ONLY."
139 LOG().info(msg)
141 settings.customer_id = customer_id
142 settings.email = email
143 if currency_code is not None:
144 settings.currency_code = currency_code
145 else:
146 name = name or self.model.contact_name
147 name_tokens: tuple[str, str] | None = None
148 if first_name is not None and last_name is not None:
149 name_tokens = (first_name, last_name)
151 inv_prefix = StripeInvoicePrefix.create(
152 uid=uid,
153 name=name,
154 email=email,
155 name_tokens=name_tokens,
156 )
158 settings = StripeCustomerModel(
159 email=email,
160 name=name,
161 customer_id=customer_id,
162 country_code=country_code,
163 invoice_prefix=inv_prefix.prefix,
164 )
165 if currency_code is not None:
166 settings.currency_code = currency_code
168 self.update_field(_K.STRIPE_SETTINGS, settings)
170 def create_stripe_account(
171 self,
172 country_code: StripeCountryCode,
173 currency_code: StripeCurrencyCode,
174 account_type: StripeAccountType,
175 account_id: str,
176 customer_id: str,
177 account_link: str | None = None,
178 account_link_expires: float | None = None,
179 stripe_disabled: bool = False,
180 stripe_disabled_reason: str | None = None,
181 stripe_closed: bool = False,
182 onboard_state: StripeOnboardState = StripeOnboardState.NOT_STARTED,
183 # the user may want to user a different name/email for stripe
184 # than in flipdare..
185 name_tokens: tuple[str, str] | None = None,
186 email: str | None = None,
187 name: str | None = None,
188 ) -> None:
189 uid = self.doc_id
190 email = email if email is not None else self.email
191 name = name if name is not None else self.model.contact_name
193 inv_prefix = StripeInvoicePrefix.create(
194 uid=uid,
195 name=name,
196 email=email,
197 name_tokens=name_tokens,
198 ).prefix
200 if IS_TRACE:
201 msg = (
202 f"Making StripeSettingsModel for user {uid} with country {country_code}, "
203 f"currency {currency_code}, account_id {account_id}, customer_id {customer_id}, "
204 f"account_link {account_link}, account_link_expires {account_link_expires}, "
205 f"account_type {account_type}, invoice_prefix {inv_prefix}, name {name}, email {email}"
206 f" onboard_state {onboard_state}, stripe_disabled {stripe_disabled}, "
207 f"stripe_disabled_reason {stripe_disabled_reason}, stripe_closed {stripe_closed}"
208 )
209 LOG().trace(msg)
211 settings = StripeAccountModel(
212 email=email,
213 name=name,
214 invoice_prefix=inv_prefix,
215 account_id=account_id,
216 account_type=account_type,
217 country_code=country_code,
218 currency_code=currency_code,
219 customer_id=customer_id,
220 account_link=account_link,
221 account_link_expires=account_link_expires,
222 onboard_state=StripeOnboardState.NOT_STARTED,
223 stripe_disabled=stripe_disabled,
224 stripe_disabled_reason=stripe_disabled_reason,
225 stripe_closed=stripe_closed,
226 )
228 self.update_field(_K.STRIPE_SETTINGS, settings)
230 def update_stripe_customer(self, **kwargs: Unpack[StripeCustomerUpdateDict]) -> None:
231 """
232 Update customer-compatible fields on the existing stripe settings.
234 Works on both StripeCustomerModel and StripeAccountModel in-place.
235 To promote a customer to an account use update_stripe_account instead.
236 """
237 old_settings = self.stripe_settings
238 if old_settings is None:
239 msg = f"User {self.doc_id} has no existing stripe settings, cannot update."
240 LOG().warning(msg)
241 return
243 update_data = {k: v for k, v in kwargs.items() if v is not None}
244 if not update_data:
245 return
247 # Both Customer and Account share the same customer field names
248 customer_fields = StripeCustomerModel.model_fields.keys()
249 filtered_data = {k: v for k, v in update_data.items() if k in customer_fields}
250 new_settings = old_settings.model_copy(update=filtered_data)
252 if old_settings.model_dump() == new_settings.model_dump():
253 if IS_TRACE:
254 LOG().trace(f"No changes to stripe customer fields for user {self.doc_id}.")
255 return
257 self.update_field(_K.STRIPE_SETTINGS, new_settings)
259 def update_stripe_account(
260 self,
261 account_id: str,
262 account_type: StripeAccountType,
263 currency_code: StripeCurrencyCode,
264 **kwargs: Unpack[StripeAccountUpdateDict],
265 ) -> None:
266 """
267 Update account fields. Promotes StripeCustomerModel → StripeAccountModel if needed.
269 The three required positional params are the minimum fields to construct a
270 StripeAccountModel. The caller is responsible for supplying valid values —
271 no partial promotion is possible.
272 """
273 old_settings = self.stripe_settings
274 if old_settings is None:
275 msg = f"User {self.doc_id} has no existing stripe settings, cannot update."
276 LOG().warning(msg)
277 return
279 # Required fields always win; optional kwargs are filtered for None
280 update_data: dict[str, object] = {k: v for k, v in kwargs.items() if v is not None}
281 update_data["account_id"] = account_id
282 update_data["account_type"] = account_type
283 update_data["currency_code"] = currency_code
285 new_settings: StripeSettingsType
286 match old_settings:
287 case StripeAccountModel():
288 new_settings = old_settings.model_copy(update=update_data)
289 case StripeCustomerModel():
290 # Promote: carry over existing customer data, overlay with caller-supplied values
291 if IS_TRACE:
292 LOG().trace(
293 f"Promoting user {self.doc_id} from Customer -> Account "
294 f"with account_id={account_id}."
295 )
296 customer_data = old_settings.model_dump(
297 exclude={"type", "updated_at", "created_at"}
298 )
299 merged = {**customer_data, **update_data}
300 onboard_state = merged.pop("onboard_state", StripeOnboardState.IN_PROGRESS)
301 new_settings = StripeAccountModel(onboard_state=onboard_state, **merged)
303 if old_settings.model_dump() == new_settings.model_dump():
304 if IS_TRACE:
305 LOG().trace(f"No changes to stripe account fields for user {self.doc_id}.")
306 return
308 self.update_field(_K.STRIPE_SETTINGS, new_settings)
310 def demote_stripe_account(self) -> None:
311 """Demote StripeAccountModel → StripeCustomerModel, discarding all account-only fields."""
312 old_settings = self.stripe_settings
313 if not StripeGuard.is_account(old_settings):
314 msg = (
315 f"User {self.doc_id} does not have stripe account settings, demotion not required."
316 )
317 LOG().info(msg)
318 return
320 if IS_TRACE:
321 LOG().trace(f"Demoting user {self.doc_id} from Account -> Customer.")
323 customer_fields = StripeCustomerModel.model_fields.keys()
324 customer_data = {
325 k: v
326 for k, v in old_settings.model_dump(exclude={"type"}).items()
327 if k in customer_fields
328 }
329 new_settings = StripeCustomerModel(**customer_data)
330 self.update_field(_K.STRIPE_SETTINGS, new_settings)
332 # <AUTO_GENERATED_CONTENT> - do not edit
334 @property
335 def contact_name(self) -> str:
336 return self._model.contact_name
338 @property
339 def tz_str(self) -> str | None:
340 return self._model.tz_str
342 @tz_str.setter
343 def tz_str(self, value: str | None) -> None:
344 self.update_field(_K.TZ_STR, value)
346 @property
347 def auth_type(self) -> AuthType:
348 return self._model.auth_type
350 @auth_type.setter
351 def auth_type(self, value: AuthType) -> None:
352 self.update_field(_K.AUTH_TYPE, value)
354 @property
355 def email(self) -> str:
356 return self._model.email
358 @email.setter
359 def email(self, value: str) -> None:
360 self.update_field(_K.EMAIL, value)
362 @property
363 def reputation(self) -> int:
364 return self._model.reputation
366 @reputation.setter
367 def reputation(self, value: int) -> None:
368 self.update_field(_K.REPUTATION, value)
370 @property
371 def fee_type(self) -> AppFeeType:
372 return self._model.fee_type
374 @fee_type.setter
375 def fee_type(self, value: AppFeeType) -> None:
376 self.update_field(_K.FEE_TYPE, value)
378 @property
379 def level(self) -> UserLevelType:
380 return self._model.level
382 @level.setter
383 def level(self, value: UserLevelType) -> None:
384 self.update_field(_K.LEVEL, value)
386 @property
387 def visibility(self) -> AppVisibility:
388 return self._model.visibility
390 @visibility.setter
391 def visibility(self, value: AppVisibility) -> None:
392 self.update_field(_K.VISIBILITY, value)
394 @property
395 def restriction_id(self) -> str | None:
396 return self._model.restriction_id
398 @restriction_id.setter
399 def restriction_id(self, value: str | None) -> None:
400 self.update_field(_K.RESTRICTION_ID, value)
402 @property
403 def invite_id(self) -> str | None:
404 return self._model.invite_id
406 @invite_id.setter
407 def invite_id(self, value: str | None) -> None:
408 self.update_field(_K.INVITE_ID, value)
410 @property
411 def facebook_token(self) -> str | None:
412 return self._model.facebook_token
414 @facebook_token.setter
415 def facebook_token(self, value: str | None) -> None:
416 self.update_field(_K.FACEBOOK_TOKEN, value)
418 @property
419 def facebook_id(self) -> str | None:
420 return self._model.facebook_id
422 @facebook_id.setter
423 def facebook_id(self, value: str | None) -> None:
424 self.update_field(_K.FACEBOOK_ID, value)
426 @property
427 def password(self) -> str | None:
428 return self._model.password
430 @password.setter
431 def password(self, value: str | None) -> None:
432 self.update_field(_K.PASSWORD, value)
434 @property
435 def pin_code(self) -> str | None:
436 return self._model.pin_code
438 @pin_code.setter
439 def pin_code(self, value: str | None) -> None:
440 self.update_field(_K.PIN_CODE, value)
442 @property
443 def delete_code(self) -> str | None:
444 return self._model.delete_code
446 @delete_code.setter
447 def delete_code(self, value: str | None) -> None:
448 self.update_field(_K.DELETE_CODE, value)
450 @property
451 def name(self) -> str | None:
452 return self._model.name
454 @name.setter
455 def name(self, value: str | None) -> None:
456 self.update_field(_K.NAME, value)
458 @property
459 def display_name(self) -> str | None:
460 return self._model.display_name
462 @display_name.setter
463 def display_name(self, value: str | None) -> None:
464 self.update_field(_K.DISPLAY_NAME, value)
466 @property
467 def description(self) -> str | None:
468 return self._model.description
470 @description.setter
471 def description(self, value: str | None) -> None:
472 self.update_field(_K.DESCRIPTION, value)
474 @property
475 def avatar(self) -> ImageModel | None:
476 return self._model.avatar
478 @avatar.setter
479 def avatar(self, value: ImageModel | None) -> None:
480 self.update_field(_K.AVATAR, value)
482 @property
483 def website_uri(self) -> str | None:
484 return self._model.website_uri
486 @website_uri.setter
487 def website_uri(self, value: str | None) -> None:
488 self.update_field(_K.WEBSITE_URI, value)
490 @property
491 def email_verified(self) -> bool:
492 return self._model.email_verified
494 @email_verified.setter
495 def email_verified(self, value: bool) -> None:
496 self.update_field(_K.EMAIL_VERIFIED, value)
498 @property
499 def must_reset_password(self) -> bool:
500 return self._model.must_reset_password
502 @must_reset_password.setter
503 def must_reset_password(self, value: bool) -> None:
504 self.update_field(_K.MUST_RESET_PASSWORD, value)
506 @property
507 def email_notifs_enabled(self) -> bool:
508 return self._model.email_notifs_enabled
510 @email_notifs_enabled.setter
511 def email_notifs_enabled(self, value: bool) -> None:
512 self.update_field(_K.EMAIL_NOTIFS_ENABLED, value)
514 @property
515 def enable_haptic(self) -> bool:
516 return self._model.enable_haptic
518 @enable_haptic.setter
519 def enable_haptic(self, value: bool) -> None:
520 self.update_field(_K.ENABLE_HAPTIC, value)
522 @property
523 def unread_activity_count(self) -> int:
524 return self._model.unread_activity_count
526 @unread_activity_count.setter
527 def unread_activity_count(self, value: int) -> None:
528 self.update_field(_K.UNREAD_ACTIVITY_COUNT, value)
530 @property
531 def video_history_count(self) -> int:
532 return self._model.video_history_count
534 @video_history_count.setter
535 def video_history_count(self, value: int) -> None:
536 self.update_field(_K.VIDEO_HISTORY_COUNT, value)
538 @property
539 def notification_count(self) -> int:
540 return self._model.notification_count
542 @notification_count.setter
543 def notification_count(self, value: int) -> None:
544 self.update_field(_K.NOTIFICATION_COUNT, value)
546 @property
547 def archive_count(self) -> int:
548 return self._model.archive_count
550 @archive_count.setter
551 def archive_count(self, value: int) -> None:
552 self.update_field(_K.ARCHIVE_COUNT, value)
554 @property
555 def auto_play_on_scroll(self) -> bool:
556 return self._model.auto_play_on_scroll
558 @auto_play_on_scroll.setter
559 def auto_play_on_scroll(self, value: bool) -> None:
560 self.update_field(_K.AUTO_PLAY_ON_SCROLL, value)
562 @property
563 def continue_type(self) -> VideoContinueType:
564 return self._model.continue_type
566 @continue_type.setter
567 def continue_type(self, value: VideoContinueType) -> None:
568 self.update_field(_K.CONTINUE_TYPE, value)
570 @property
571 def auto_mute(self) -> bool:
572 return self._model.auto_mute
574 @auto_mute.setter
575 def auto_mute(self, value: bool) -> None:
576 self.update_field(_K.AUTO_MUTE, value)
578 @property
579 def swipe_left_to_archive(self) -> bool:
580 return self._model.swipe_left_to_archive
582 @swipe_left_to_archive.setter
583 def swipe_left_to_archive(self, value: bool) -> None:
584 self.update_field(_K.SWIPE_LEFT_TO_ARCHIVE, value)
586 @property
587 def prompt_for_confirmation(self) -> bool:
588 return self._model.prompt_for_confirmation
590 @prompt_for_confirmation.setter
591 def prompt_for_confirmation(self, value: bool) -> None:
592 self.update_field(_K.PROMPT_FOR_CONFIRMATION, value)
594 @property
595 def show_system_notifications(self) -> bool:
596 return self._model.show_system_notifications
598 @show_system_notifications.setter
599 def show_system_notifications(self, value: bool) -> None:
600 self.update_field(_K.SHOW_SYSTEM_NOTIFICATIONS, value)
602 @property
603 def cache_size(self) -> UserCacheType:
604 return self._model.cache_size
606 @cache_size.setter
607 def cache_size(self, value: UserCacheType) -> None:
608 self.update_field(_K.CACHE_SIZE, value)
610 @property
611 def archive_time(self) -> UserArchiveType:
612 return self._model.archive_time
614 @archive_time.setter
615 def archive_time(self, value: UserArchiveType) -> None:
616 self.update_field(_K.ARCHIVE_TIME, value)
618 @property
619 def view_stats(self) -> ViewStatsModel:
620 return self._model.view_stats
622 @view_stats.setter
623 def view_stats(self, value: ViewStatsModel) -> None:
624 self.update_field(_K.VIEW_STATS, value)
626 @property
627 def dare_stats(self) -> DareStatsModel:
628 return self._model.dare_stats
630 @dare_stats.setter
631 def dare_stats(self, value: DareStatsModel) -> None:
632 self.update_field(_K.DARE_STATS, value)
634 @property
635 def stripe_settings(self) -> StripeSettingsType | None:
636 return self._model.stripe_settings
638 @stripe_settings.setter
639 def stripe_settings(self, value: StripeSettingsType | None) -> None:
640 self.update_field(_K.STRIPE_SETTINGS, value)
642 @property
643 def location(self) -> LocationModel | None:
644 return self._model.location
646 @location.setter
647 def location(self, value: LocationModel | None) -> None:
648 self.update_field(_K.LOCATION, value)
650 @property
651 def flagged(self) -> FlagModel | None:
652 return self._model.flagged
654 @flagged.setter
655 def flagged(self, value: FlagModel | None) -> None:
656 self.update_field(_K.FLAGGED, value)
658 # base internal fields
659 @property
660 def version(self) -> int:
661 return self._model.version
663 @version.setter
664 def version(self, value: int) -> None:
665 self.update_field(_K.VERSION, value)
667 @property
668 def processed(self) -> bool:
669 return self._model.processed
671 @processed.setter
672 def processed(self, value: bool) -> None:
673 self.update_field(_K.PROCESSED, value)
675 @property
676 def error_count(self) -> int:
677 return self._model.error_count
679 @error_count.setter
680 def error_count(self, value: int) -> None:
681 self.update_field(_K.ERROR_COUNT, value)
683 # user specific internal fields
684 @property
685 def invite_processed(self) -> bool:
686 return self._model.invite_processed
688 @invite_processed.setter
689 def invite_processed(self, value: bool) -> None:
690 self.update_field(_K.INVITE_PROCESSED, value)
692 @property
693 def context_created(self) -> bool:
694 return self._model.context_created
696 @context_created.setter
697 def context_created(self, value: bool) -> None:
698 self.update_field(_K.CONTEXT_CREATED, value)
700 @property
701 def search_indexed(self) -> bool:
702 return self._model.search_indexed
704 @search_indexed.setter
705 def search_indexed(self, value: bool) -> None:
706 self.update_field(_K.SEARCH_INDEXED, value)
708 # </AUTO_GENERATED_CONTENT> - do not edit