Updated on 2026-08-14

This commit is contained in:
Tangem 2026-04-30 11:31:12 +04:00
commit 4613836a1b
37 changed files with 623 additions and 311 deletions

View file

@ -40,7 +40,6 @@ internal class DefaultScanCardProcessor(
cardId: String?,
onProgressStateChange: suspend (showProgress: Boolean) -> Unit,
onWalletNotCreated: suspend () -> Unit,
disclaimerWillShow: () -> Unit,
onCancel: suspend () -> Unit,
onFailure: suspend (error: TangemError) -> Unit,
onSuccess: suspend (scanResponse: ScanResponse) -> Unit,
@ -51,7 +50,6 @@ internal class DefaultScanCardProcessor(
cardId = cardId,
onProgressStateChange = onProgressStateChange,
onWalletNotCreated = onWalletNotCreated,
disclaimerWillShow = disclaimerWillShow,
onFailure = onFailure,
onSuccess = onSuccess,
)
@ -62,7 +60,6 @@ internal class DefaultScanCardProcessor(
cardId = cardId,
onProgressStateChange = onProgressStateChange,
onWalletNotCreated = onWalletNotCreated,
disclaimerWillShow = disclaimerWillShow,
onCancel = onCancel,
onFailure = onFailure,
onSuccess = onSuccess,

View file

@ -22,7 +22,6 @@ import com.tangem.core.ui.extensions.toWrappedList
import com.tangem.core.ui.message.dialog.Dialogs
import com.tangem.domain.card.ScanFailsCounter
import com.tangem.domain.card.common.util.twinsIsTwinned
import com.tangem.domain.card.repository.CardRepository
import com.tangem.domain.common.extensions.withMainContext
import com.tangem.domain.feedback.SendFeedbackEmailUseCase
import com.tangem.domain.feedback.models.FeedbackEmailType
@ -32,13 +31,10 @@ import com.tangem.sdk.extensions.localizedDescriptionRes
import com.tangem.tap.common.analytics.paramsInterceptor.CardContextInterceptor
import com.tangem.tap.features.onboarding.OnboardingHelper
import com.tangem.tap.mainScope
import com.tangem.tap.scope
import com.tangem.tap.tangemSdkManager
import com.tangem.utils.extensions.DELAY_SDK_DIALOG_CLOSE
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import javax.inject.Inject
import javax.inject.Singleton
@ -52,7 +48,6 @@ internal class LegacyScanProcessor @Inject constructor(
private val appRouter: AppRouter,
private val sendFeedbackEmailUseCase: SendFeedbackEmailUseCase,
private val wasTwinsOnboardingShownUseCase: WasTwinsOnboardingShownUseCase,
private val cardRepository: CardRepository,
private val onboardingHelper: OnboardingHelper,
) {
@ -79,7 +74,6 @@ internal class LegacyScanProcessor @Inject constructor(
cardId: String?,
onProgressStateChange: suspend (showProgress: Boolean) -> Unit,
onWalletNotCreated: suspend () -> Unit,
disclaimerWillShow: () -> Unit,
onCancel: suspend () -> Unit,
onFailure: suspend (error: TangemError) -> Unit,
onSuccess: suspend (scanResponse: ScanResponse) -> Unit,
@ -119,17 +113,11 @@ internal class LegacyScanProcessor @Inject constructor(
sendAnalytics(analyticsEvent, scanResponse)
showDisclaimerIfNeed(
onScanSuccess(
scanResponse = scanResponse,
disclaimerWillShow = disclaimerWillShow,
nextHandler = { scanResponse2 ->
onScanSuccess(
scanResponse = scanResponse2,
onProgressStateChange = onProgressStateChange,
onSuccess = onSuccess,
onWalletNotCreated = onWalletNotCreated,
)
},
onProgressStateChange = onProgressStateChange,
onSuccess = onSuccess,
onWalletNotCreated = onWalletNotCreated,
)
}
}
@ -144,26 +132,6 @@ internal class LegacyScanProcessor @Inject constructor(
Analytics.send(analyticsEvent)
}
private suspend inline fun showDisclaimerIfNeed(
scanResponse: ScanResponse,
crossinline disclaimerWillShow: () -> Unit = {},
crossinline nextHandler: suspend (ScanResponse) -> Unit,
) {
if (cardRepository.isTangemTOSAccepted()) {
nextHandler(scanResponse)
} else {
scope.launch {
delay(DELAY_SDK_DIALOG_CLOSE)
withContext(Dispatchers.Main.immediate) {
disclaimerWillShow()
appRouter.push(AppRoute.Disclaimer(isTosAccepted = false))
}
}
}
}
private suspend inline fun onScanFailure(
analyticsSource: AnalyticsParam.ScreensSources,
error: TangemError,

View file

@ -13,11 +13,13 @@ import com.tangem.core.analytics.utils.TrackingContextProxy
import com.tangem.domain.card.ScanCardException
import com.tangem.domain.card.ScanCardUseCase
import com.tangem.domain.card.ScanFailsRequester
import com.tangem.domain.card.repository.CardRepository
import com.tangem.domain.models.scan.ScanResponse
import com.tangem.domain.onboarding.WasTwinsOnboardingShownUseCase
import com.tangem.tap.common.analytics.events.TangemSdkErrorEvent
import com.tangem.tap.domain.scanCard.chains.*
import com.tangem.tap.domain.scanCard.chains.AnalyticsChain
import com.tangem.tap.domain.scanCard.chains.CheckForOnboardingChain
import com.tangem.tap.domain.scanCard.chains.FailedScansCounterChain
import com.tangem.tap.domain.scanCard.chains.ScanChainException
import com.tangem.tap.domain.scanCard.utils.ScanCardExceptionConverter
import com.tangem.tap.features.onboarding.OnboardingHelper
import com.tangem.tap.scope
@ -35,7 +37,6 @@ internal class UseCaseScanProcessor @Inject constructor(
private val appRouter: AppRouter,
private val trackingContextProxy: TrackingContextProxy,
private val wasTwinsOnboardingShownUseCase: WasTwinsOnboardingShownUseCase,
private val cardRepository: CardRepository,
private val onboardingHelper: OnboardingHelper,
) {
private val scanCardExceptionConverter = ScanCardExceptionConverter()
@ -62,7 +63,6 @@ internal class UseCaseScanProcessor @Inject constructor(
cardId: String?,
onProgressStateChange: suspend (showProgress: Boolean) -> Unit,
onWalletNotCreated: suspend () -> Unit,
disclaimerWillShow: () -> Unit,
onFailure: suspend (error: TangemError) -> Unit,
onSuccess: suspend (scanResponse: ScanResponse) -> Unit,
) = progressScope(onProgressStateChange) {
@ -73,7 +73,6 @@ internal class UseCaseScanProcessor @Inject constructor(
),
)
add(AnalyticsChain(Basic.CardWasScanned(analyticsSource)))
add(DisclaimerChain(appRouter, cardRepository, disclaimerWillShow))
add(CheckForOnboardingChain(trackingContextProxy, wasTwinsOnboardingShownUseCase, onboardingHelper))
}

View file

@ -1,37 +0,0 @@
package com.tangem.tap.domain.scanCard.chains
import arrow.core.right
import com.tangem.common.routing.AppRoute
import com.tangem.common.routing.AppRouter
import com.tangem.domain.card.ScanCardException
import com.tangem.domain.card.repository.CardRepository
import com.tangem.domain.core.chain.Chain
import com.tangem.domain.core.chain.ResultChain
import com.tangem.domain.models.scan.ScanResponse
/**
* Handles disclaimer display after the card scanning operation.
*
* @param disclaimerWillShow an optional function to be invoked when a disclaimer is about to be shown. Default is an
* empty function.
*
* @see Chain for more information about the Chain interface.
*/
internal class DisclaimerChain(
private val appRouter: AppRouter,
private val cardRepository: CardRepository,
private val disclaimerWillShow: () -> Unit = {},
) : ResultChain<ScanCardException, ScanResponse>() {
override suspend fun launch(previousChainResult: ScanResponse): ScanChainResult {
return if (cardRepository.isTangemTOSAccepted()) {
previousChainResult.right()
} else {
disclaimerWillShow()
appRouter.push(route = AppRoute.Disclaimer(isTosAccepted = false))
previousChainResult.right()
}
}
}

View file

@ -21,6 +21,7 @@ import com.tangem.domain.common.wallets.error.*
import com.tangem.domain.hotwallet.repository.HotWalletRepository
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.models.wallet.isImported
import com.tangem.domain.models.wallet.isLocked
import com.tangem.domain.wallets.R
import com.tangem.domain.wallets.analytics.WalletSettingsAnalyticEvents
@ -603,10 +604,16 @@ internal class DefaultUserWalletsListRepository(
private fun trackSignInEvent(userWallet: UserWallet, type: Basic.SignedIn.SignInType) {
trackingContextProxy.addContext(userWallet)
val isBackedUp = when (userWallet) {
is UserWallet.Cold -> userWallet.scanResponse.card.backupStatus?.isActive == true
is UserWallet.Hot -> userWallet.backedUp
}
analyticsEventHandler.send(
event = Basic.SignedIn(
signInType = type,
walletsCount = userWallets.value?.size ?: 0,
isImported = userWallet.isImported(),
hasBackup = isBackedUp,
),
)
}

View file

@ -29,6 +29,8 @@ import com.tangem.core.ui.message.SnackbarMessage
import com.tangem.domain.card.repository.CardRepository
import com.tangem.domain.common.wallets.UserWalletsListRepository
import com.tangem.domain.models.scan.ScanResponse
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.isImported
import com.tangem.domain.models.wallet.isLocked
import com.tangem.domain.onboarding.repository.OnboardingRepository
import com.tangem.features.hotwallet.HotAccessCodeRequestComponent
@ -170,14 +172,7 @@ internal class DefaultRoutingComponent @AssistedInject constructor(
val userWallets = userWalletsListRepository.userWalletsSync()
return when {
userWallets.isEmpty() -> {
val shouldShowTos = !cardRepository.isTangemTOSAccepted()
if (shouldShowTos) {
AppRoute.Disclaimer(isTosAccepted = false)
} else {
AppRoute.Home(launchMode = launchMode)
}
}
userWallets.isEmpty() -> AppRoute.Home(launchMode = launchMode)
userWallets.any { it.isLocked } -> {
AppRoute.Welcome(
launchMode = launchMode,
@ -339,10 +334,16 @@ internal class DefaultRoutingComponent @AssistedInject constructor(
val userWallets = userWalletsListRepository.userWalletsSync()
val selectedWallet = userWalletsListRepository.selectedUserWalletSync() ?: return
trackingContextProxy.addContext(selectedWallet)
val isBackedUp = when (selectedWallet) {
is UserWallet.Cold -> selectedWallet.scanResponse.card.backupStatus?.isActive == true
is UserWallet.Hot -> selectedWallet.backedUp
}
analyticsEventHandler.send(
event = Basic.SignedIn(
signInType = Basic.SignedIn.SignInType.NoSecurity,
walletsCount = userWallets.size,
isImported = selectedWallet.isImported(),
hasBackup = isBackedUp,
),
)
}

View file

@ -42,11 +42,17 @@ sealed class Basic(
class SignedIn(
signInType: SignInType,
walletsCount: Int,
isImported: Boolean,
hasBackup: Boolean?,
) : Basic(
event = "Signed in",
params = buildMap {
put("Sign in type", signInType.value)
put("Wallets Count", walletsCount.toString())
put("Wallet Type", if (isImported) "Seed Phrase" else "Seedless")
if (hasBackup != null) {
put("Backuped", if (hasBackup) "Yes" else "No")
}
},
) {
enum class SignInType(val value: String) {

View file

@ -53,6 +53,7 @@ class MoshiModule {
.withSubtype(PaymentAccountStatusValueDM.UnderReview::class.java, "kyc_status")
.withSubtype(PaymentAccountStatusValueDM.IssuingCard::class.java, "issuing_card")
.withSubtype(PaymentAccountStatusValueDM.ActiveAccount::class.java, "active_account")
.withSubtype(PaymentAccountStatusValueDM.DeactivatedAccount::class.java, "deactivated_account")
.withSubtype(PaymentAccountStatusValueDM.CardIssueFailed::class.java, "card_issue_failed"),
)
.add(

View file

@ -54,6 +54,12 @@ sealed interface PaymentAccountStatusValueDM {
@Json(name = "customer_id") val customerId: String,
) : PaymentAccountStatusValueDM
@NameLabel("deactivated_account")
data class DeactivatedAccount(
@Json(name = "deactivated_account") val marker: Boolean = true,
@Json(name = "fiat_balance") val fiatBalance: FiatBalanceDM,
) : PaymentAccountStatusValueDM
@JsonClass(generateAdapter = true)
data class FiatBalanceDM(
@Json(name = "available_balance") val availableBalance: BigDecimal,

View file

@ -150,6 +150,9 @@
<string name="balance_hidden_do_not_show_button">Don\'t show again</string>
<string name="balance_hidden_got_it_button">Got it</string>
<string name="balance_hidden_title">Balances are hidden</string>
<string name="banner.swap.firsttime.button">Start Swap</string>
<string name="banner.swap.firsttime.subtitletitle">Turn one asset into another in just a few taps</string>
<string name="banner.swap.firsttime.title">Make your first swap</string>
<string name="beta_mode_warning_message">According to the blockchain developers, Kaspa tokens are currently in beta. Stay tuned for updates!</string>
<string name="beta_mode_warning_title">Beta Mode</string>
<string name="biometric_disabled_warning_description">Biometrics are turned off on your device, so you can\'t use them to unlock your wallets. Enable biometrics in your device settings to use this method again.</string>
@ -1540,6 +1543,7 @@
<string name="swap_approve_description">By approving, you allow the smart contract to use your tokens in future transactions.</string>
<string name="swap_fixed_rate">Fixed Rate</string>
<string name="swap_give_permission_fee_footer">The network will charge a token approval fee to verify that you are authorizing the use of your token for the swap.</string>
<string name="swap_in_progress">Swap in progress</string>
<string name="swap_promo_text">Exchange more tokens at better rates directly in your wallet.</string>
<string name="swap_promo_title">New Swap Provider Available!</string>
<string name="swap_search_suggestion_hint">Looking for something else?\nTry searching or explore another crypto!</string>
@ -1548,11 +1552,11 @@
<string name="swap_story_fifth_subtitle">Feel confident with round-the-clock support to help with any issues</string>
<string name="swap_story_fifth_title">Always Here</string>
<string name="swap_story_first_subtitle">Multiple trusted providers in one place—swap any asset effortlessly in your wallet</string>
<string name="swap_story_first_subtitle_v2">Exchange crypto directly in Tangem\nNo additional transfers\nNo moving funds to exchanges</string>
<string name="swap_story_first_subtitle_v2">Exchange crypto directly in Tangem\nNo additional transfers\nNo moving funds to exchanges</string>
<string name="swap_story_first_title">Swap With Us</string>
<string name="swap_story_first_title_v2">Swap Inside Your Wallet</string>
<string name="swap_story_forth_subtitle">No fumbles, no turnovers, no blind spots—your transaction is always protected</string>
<string name="swap_story_forth_subtitle_v2">Swaps are executed through trusted providers. Your keys remain in your Tangem wallet at all times. Clear. Transparent. Self-custodial.</string>
<string name="swap_story_forth_subtitle_v2">Swaps are executed through trusted providers. Your keys remain in your Tangem wallet at all times. Clear. Transparent. Self-custodial.</string>
<string name="swap_story_forth_title">Impenetrable Defense</string>
<string name="swap_story_forth_title_v2">You Stay in Control</string>
<string name="swap_story_second_subtitle">Maximize your value with rates sourced from a wide network of trusted providers, always choosing the best one</string>
@ -1562,7 +1566,7 @@
<string name="swap_story_third_subtitle">Hassle-free and intuitive, allowing you to swap tokens in just a few taps</string>
<string name="swap_story_third_subtitle_v2">Swap across major networksand thousands of tokens 0% fee on stablecoin-to-stablecoin swaps</string>
<string name="swap_story_third_title">Simply Convenient</string>
<string name="swap_story_third_title_v2">90+ Blockchains\n16,000+ Assets</string>
<string name="swap_story_third_title_v2">90+ Blockchains\n16,000+ Assets</string>
<string name="swap_via_provider">Swap via provider</string>
<string name="swap_your_assets_title">Your assets</string>
<string name="swapping_alert_cex_description">The amount includes:\n• service provider\'s fee\n• network fee for sending %s from the exchange back to the user\'s address.</string>
@ -1573,7 +1577,9 @@
<string name="swapping_approve_information_text">All decentralized exchanges require approvals to prevent smart contracts from accessing your wallet without your permission. By design, smart contracts can\'t access your tokens unless you approve. By \"unlocking\" your tokens, you authorize the 1-inch smart contract to spend them. The network\'s miners receive a gas fee (paid by you) to record this action on the blockchain. You can swap your token after giving approval.</string>
<string name="swapping_approve_information_title">Approve</string>
<string name="swapping_fee_estimation_error_text">Fee estimation error. Please send feedback to support.</string>
<string name="swapping_from_account_title">You send from</string>
<string name="swapping_from_title">You swap</string>
<string name="swapping_from_title_v2">You send</string>
<string name="swapping_high_price_impact_description">Swapping this amount of selected tokens will cause a significant price impact and reduce your outcome.</string>
<string name="swapping_high_price_impact_text">You may receive significantly less due to low liquidity. Try a smaller amount or another provider.</string>
<string name="swapping_high_price_impact_title">High price impact</string>
@ -1582,6 +1588,7 @@
<string name="swapping_permission_header">Give Permission</string>
<string name="swapping_swap_action">Swap</string>
<string name="swapping_swap_action_in_progress">Swapping...</string>
<string name="swapping_to_account_title">You receive to</string>
<string name="swapping_to_title">You receive</string>
<string name="swapping_token_list_title">Choose token</string>
<string name="swapping_token_not_available">not available</string>
@ -1661,13 +1668,13 @@
<string name="tangempay_card_details_receive_error_description">Technical issues detected. Please try again later or contact support.</string>
<string name="tangempay_card_details_receive_error_title">Receive unavailable now</string>
<string name="tangempay_card_details_reissue_card">Replace card</string>
<string name="tangempay_card_details_rename_card_invalid_description">Only letters and numbers are allowed.</string>
<string name="tangempay_card_details_rename_card_invalid_description">Only letters and numbers are allowed</string>
<string name="tangempay_card_details_rename_card_invalid_title">Invalid characters</string>
<string name="tangempay_card_details_reveal_text">Reveal</string>
<string name="tangempay_card_details_show_details">Show details</string>
<string name="tangempay_card_details_swap_description">Swap any asset in your portfolio for card</string>
<string name="tangempay_card_details_title">Card details</string>
<string name="tangempay_card_details_unable_to_rename_card_description">Please try again later.</string>
<string name="tangempay_card_details_unable_to_rename_card_description">Please try again later</string>
<string name="tangempay_card_details_unfreeze_card">Unfreeze Card</string>
<string name="tangempay_card_details_view_pin_code_description">Come back to the app if you forget it.</string>
<string name="tangempay_card_details_view_pin_code_title">Your PIN code</string>
@ -1676,7 +1683,7 @@
<string name="tangempay_card_details_withdraw_in_progress_description">You can\'t initiate swap or new withdrawal till the current one is finished</string>
<string name="tangempay_card_details_withdraw_in_progress_title">Withdrawal in progress</string>
<string name="tangempay_card_limit_setup_amount_subtitle">Set a limit from %s</string>
<string name="tangempay_card_limit_setup_error_message">We couldnt set the limit. Please try again.</string>
<string name="tangempay_card_limit_setup_error_message">We couldnt set the limit. Please try again</string>
<string name="tangempay_card_page_daily_limit_change">Change</string>
<string name="tangempay_card_page_daily_limit_current_limit">Current limit</string>
<string name="tangempay_card_page_daily_limit_error_description">We couldn\'t load your daily limit. Please try again.</string>
@ -1753,7 +1760,7 @@
<string name="tangempay_title">Tangem Pay</string>
<string name="tangempay_usdc_on_polygon_network">USDC on Polygon network</string>
<string name="tangempay_use_tangem_device_to_restore_payment_account">Click the button below to restore access</string>
<string name="tangempay_withdrawal_note_description">Your on-chain USDC Polygon balance differs from your card balance and updates within 2 business days after a purchase. Funds from refunded purchases wont be returned your on-chain balance or be available for withdrawal, but will stay on your card balance for purchases.</string>
<string name="tangempay_withdrawal_note_description">Funds from refunded purchases wont be returned your on-chain Polygon balance or be available for withdrawal, but will stay on your card balance for purchases</string>
<string name="tangempay_withdrawal_note_title">Please note</string>
<string name="tangempay_your_pin_code">Your PIN code</string>
<string name="this_is_my_wallet_title">This is my wallet</string>

View file

@ -12,6 +12,10 @@ android {
namespace = "com.tangem.data.visa"
}
tasks.withType<Test>().configureEach {
useJUnitPlatform()
}
dependencies {
/** Project - Data */
@ -68,4 +72,9 @@ dependencies {
/** DI */
implementation(deps.hilt.android)
kapt(deps.hilt.kapt)
/** Test */
testRuntimeOnly(deps.test.junit5.engine)
testImplementation(projects.common.test)
testImplementation(projects.test.core)
}

View file

@ -57,6 +57,9 @@ internal class PaymentAccountStatusValueDMConverter @Inject constructor(
customerId = value.customerId,
)
is PaymentAccountStatusValue.Empty -> PaymentAccountStatusValueDM.Empty()
is PaymentAccountStatusValue.Deactivated -> PaymentAccountStatusValueDM.DeactivatedAccount(
fiatBalance = value.fiatBalance.toDM(),
)
// Transient statuses are not persisted
is PaymentAccountStatusValue.Loading,
is PaymentAccountStatusValue.Error.ExposedDevice,
@ -107,6 +110,10 @@ internal class PaymentAccountStatusValueDMConverter @Inject constructor(
kycStatus = value.kycStatus,
customerId = value.customerId,
)
is PaymentAccountStatusValueDM.DeactivatedAccount -> PaymentAccountStatusValue.Deactivated(
source = StatusSource.CACHE,
fiatBalance = value.fiatBalance.toDomain(),
)
null -> PaymentAccountStatusValue.Error.Unavailable
}
}

View file

@ -52,16 +52,6 @@ internal class DefaultPaymentAccountStatusFetcher @Inject constructor(
val account = Account.Payment(userWalletId = params.userWalletId)
logger.i("fetch: ${params.userWalletId.stringValue}")
if (onboardingRepository.isTangemPayDeactivated(params.userWalletId)) {
return@catchOn paymentAccountStatusesStore.store(
userWalletId = params.userWalletId,
status = AccountStatus.Payment(
account = account,
value = PaymentAccountStatusValue.Empty,
),
)
}
if (deviceSecurity.isSecurityExposed()) {
logger.i("fetch security info: rooted: ${deviceSecurity.isRooted}")
logger.i("fetch security info: xposed: ${deviceSecurity.isXposed}")
@ -256,21 +246,32 @@ internal class DefaultPaymentAccountStatusFetcher @Inject constructor(
private fun CustomerInfo.mapToPaymentAccountStatus(userWalletId: UserWalletId): PaymentAccountStatusValue {
val cardInfo = this.cardInfo
val productInstance = this.productInstance
return if (kycStatus != KycStatus.APPROVED && !customerId.isNullOrEmpty()) {
PaymentAccountStatusValue.UnderReview(
source = StatusSource.ACTUAL,
kycStatus = kycStatus,
customerId = requireNotNull(customerId) { "CustomerId must not be null" },
)
} else if (cardInfo != null && productInstance != null && !customerId.isNullOrEmpty()) {
convertToContentState(
val isDeactivated = productInstance?.status == CustomerInfo.ProductInstance.Status.DEACTIVATED
val isFormer = state == CustomerInfo.State.FORMER
val fiatBalance = fiatBalance
return when {
kycStatus != KycStatus.APPROVED && !customerId.isNullOrEmpty() -> {
PaymentAccountStatusValue.UnderReview(
source = StatusSource.ACTUAL,
kycStatus = kycStatus,
customerId = requireNotNull(customerId) { "CustomerId must not be null" },
)
}
fiatBalance != null && (isDeactivated || isFormer) -> {
PaymentAccountStatusValue.Deactivated(
source = StatusSource.ACTUAL,
fiatBalance = fiatBalance,
)
}
cardInfo != null && productInstance != null && !customerId.isNullOrEmpty() -> convertToContentState(
userWalletId = userWalletId,
productInstance = productInstance,
cardInfo = cardInfo,
customerId = requireNotNull(customerId) { "CustomerId must not be null" },
)
} else {
PaymentAccountStatusValue.IssuingCard(source = StatusSource.ACTUAL)
else -> PaymentAccountStatusValue.IssuingCard(source = StatusSource.ACTUAL)
}
}
@ -309,7 +310,6 @@ internal class DefaultPaymentAccountStatusFetcher @Inject constructor(
return when (this) {
is VisaApiError.RefreshTokenExpired -> PaymentAccountStatusValue.Error.NotSynced
is VisaApiError.NotPaeraCustomer -> constructNotCreatedOrEmptyStatus(userWalletId)
is VisaApiError.Deactivated -> constructNotCreatedOrEmptyStatus(userWalletId)
else -> PaymentAccountStatusValue.Error.Unavailable
}
}

View file

@ -3,7 +3,6 @@ package com.tangem.data.pay.repository
import arrow.core.Either
import arrow.core.flatMap
import arrow.core.getOrElse
import arrow.core.left
import arrow.core.right
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.data.pay.store.PaymentAccountStatusesStore
@ -12,24 +11,25 @@ import com.tangem.datasource.api.pay.models.request.DeeplinkValidityRequest
import com.tangem.datasource.api.pay.models.request.OrderRequest
import com.tangem.datasource.api.pay.models.request.SetTangemPayEnabledRequest
import com.tangem.datasource.api.pay.models.response.CustomerMeResponse
import com.tangem.datasource.api.pay.models.response.FiatBalance
import com.tangem.datasource.api.pay.models.response.OrderResponse
import com.tangem.datasource.local.visa.TangemPayCardFrozenStateStore
import com.tangem.datasource.local.visa.TangemPayStorage
import com.tangem.domain.common.wallets.UserWalletsListRepository
import com.tangem.domain.models.pay.TangemPayEligibilityType
import com.tangem.domain.models.account.CardDisplayName
import com.tangem.domain.models.account.Account
import com.tangem.domain.models.account.AccountStatus
import com.tangem.domain.models.account.CardDisplayName
import com.tangem.domain.models.account.PaymentAccountStatusValue
import com.tangem.domain.models.kyc.KycStatus
import com.tangem.domain.models.pay.TangemPayCardLimit
import com.tangem.domain.models.pay.TangemPayCardLimitPeriod
import com.tangem.domain.models.pay.TangemPayEligibilityType
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.pay.datasource.TangemPayAuthDataSource
import com.tangem.domain.pay.model.CustomerInfo
import com.tangem.domain.pay.model.CustomerInfo.CardInfo
import com.tangem.domain.pay.model.CustomerInfo.ProductInstance
import com.tangem.domain.models.pay.TangemPayCardLimit
import com.tangem.domain.models.pay.TangemPayCardLimitPeriod
import com.tangem.domain.pay.repository.OnboardingRepository
import com.tangem.domain.tangempay.TangemPayAnalyticsEvents
import com.tangem.domain.visa.error.VisaApiError
@ -105,10 +105,8 @@ internal class DefaultOnboardingRepository @Inject constructor(
val isFormer = result?.state?.let { CustomerInfo.State.fromString(it) } == CustomerInfo.State.FORMER
if (isDeactivated || isFormer) {
tangemPayStorage.storeIsTangemPayDeactivated(userWalletId)
VisaApiError.Deactivated.left()
} else {
getCustomerInfo(userWalletId = userWalletId, response = result).right()
}
getCustomerInfo(userWalletId = userWalletId, response = result).right()
}
}
@ -184,10 +182,7 @@ internal class DefaultOnboardingRepository @Inject constructor(
currencyCode = fiatBalance.currency,
depositAddress = response.depositAddress,
isPinSet = response.card?.isPinSet == true,
fiatBalance = PaymentAccountStatusValue.FiatBalance(
availableBalance = fiatBalance.availableBalance,
currency = fiatBalance.currency,
),
fiatBalance = fiatBalance.toDomain(),
cryptoBalance = PaymentAccountStatusValue.CryptoBalance(
id = cryptoBalance.id,
chainId = cryptoBalance.chainId.toLong(),
@ -212,6 +207,7 @@ internal class DefaultOnboardingRepository @Inject constructor(
id = instance.id,
cardId = instance.cardId,
frozenState = cardFrozenState,
status = instance.status.toDomain(),
displayName = if (displayName != null) CardDisplayName(displayName).getOrElse { null } else null,
actualCardLimit = instance.actualCardLimit?.parseCardLimit(),
adminCardLimit = instance.adminCardLimit?.parseCardLimit(),
@ -222,6 +218,8 @@ internal class DefaultOnboardingRepository @Inject constructor(
productInstance = productInstance,
kycStatus = kycStatus,
cardInfo = cardInfo,
state = response?.state?.let { CustomerInfo.State.fromString(it) } ?: CustomerInfo.State.UNDEFINED,
fiatBalance = fiatBalance?.toDomain(),
).also {
lastFetchedCustomerInfoMap[userWalletId] = it
}
@ -310,4 +308,24 @@ internal class DefaultOnboardingRepository @Inject constructor(
setHideMainOnboardingBanner(userWalletId)
}
}
}
private fun FiatBalance.toDomain() = PaymentAccountStatusValue.FiatBalance(
availableBalance = availableBalance,
currency = currency,
)
private fun CustomerMeResponse.ProductInstance.Status.toDomain() = when (this) {
CustomerMeResponse.ProductInstance.Status.NEW -> ProductInstance.Status.NEW
CustomerMeResponse.ProductInstance.Status.READY_FOR_MANUFACTURING -> ProductInstance.Status.READY_FOR_MANUFACTURING
CustomerMeResponse.ProductInstance.Status.MANUFACTURING -> ProductInstance.Status.MANUFACTURING
CustomerMeResponse.ProductInstance.Status.SENT_TO_DELIVERY -> ProductInstance.Status.SENT_TO_DELIVERY
CustomerMeResponse.ProductInstance.Status.DELIVERED -> ProductInstance.Status.DELIVERED
CustomerMeResponse.ProductInstance.Status.ACTIVATING -> ProductInstance.Status.ACTIVATING
CustomerMeResponse.ProductInstance.Status.ACTIVE -> ProductInstance.Status.ACTIVE
CustomerMeResponse.ProductInstance.Status.BLOCKED -> ProductInstance.Status.BLOCKED
CustomerMeResponse.ProductInstance.Status.DEACTIVATING -> ProductInstance.Status.DEACTIVATING
CustomerMeResponse.ProductInstance.Status.DEACTIVATED -> ProductInstance.Status.DEACTIVATED
CustomerMeResponse.ProductInstance.Status.CANCELED -> ProductInstance.Status.CANCELED
CustomerMeResponse.ProductInstance.Status.UNKNOWN -> ProductInstance.Status.UNKNOWN
}

View file

@ -0,0 +1,155 @@
package com.tangem.data.pay.converter
import com.google.common.truth.Truth.assertThat
import com.tangem.data.pay.entity.TangemPayCurrencyFactory
import com.tangem.datasource.local.visa.entity.PaymentAccountStatusValueDM
import com.tangem.domain.models.StatusSource
import com.tangem.domain.models.account.PaymentAccountStatusValue
import com.tangem.domain.models.wallet.UserWalletId
import io.mockk.mockk
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
import java.math.BigDecimal
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class PaymentAccountStatusValueDMConverterTest {
private val tangemPayCurrencyFactory: TangemPayCurrencyFactory = mockk()
private val userWalletId = UserWalletId("1234567890ABCDEF")
private val converter = PaymentAccountStatusValueDMConverter(tangemPayCurrencyFactory)
@Nested
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
inner class Convert {
@Test
fun `GIVEN domain Empty WHEN convert THEN returns DM Empty`() {
// GIVEN
val domain = PaymentAccountStatusValue.Empty
// WHEN
val result = converter.convert(domain)
// THEN
assertThat(result).isInstanceOf(PaymentAccountStatusValueDM.Empty::class.java)
}
@Test
fun `GIVEN domain Deactivated with balance WHEN convert THEN returns DM DeactivatedAccount with same balance`() {
// GIVEN
val domain = PaymentAccountStatusValue.Deactivated(
source = StatusSource.ACTUAL,
fiatBalance = PaymentAccountStatusValue.FiatBalance(
availableBalance = BigDecimal("100"),
currency = "USD",
)
)
// WHEN
val result = converter.convert(domain)
// THEN
assertThat(result).isInstanceOf(PaymentAccountStatusValueDM.DeactivatedAccount::class.java)
val dm = result as PaymentAccountStatusValueDM.DeactivatedAccount
assertThat(dm.fiatBalance.availableBalance).isEqualTo(BigDecimal("100"))
assertThat(dm.fiatBalance.currency).isEqualTo("USD")
}
@Test
fun `GIVEN domain Loading WHEN convert THEN returns null (transient, not persisted)`() {
// GIVEN
val domain = PaymentAccountStatusValue.Loading
// WHEN
val result = converter.convert(domain)
// THEN
assertThat(result).isNull()
}
@Test
fun `GIVEN domain Error Unavailable WHEN convert THEN returns null (transient, not persisted)`() {
// GIVEN
val domain = PaymentAccountStatusValue.Error.Unavailable
// WHEN
val result = converter.convert(domain)
// THEN
assertThat(result).isNull()
}
@Test
fun `GIVEN domain NotCreated WHEN convert THEN returns DM NotCreated`() {
// GIVEN
val domain = PaymentAccountStatusValue.NotCreated
// WHEN
val result = converter.convert(domain)
// THEN
assertThat(result).isInstanceOf(PaymentAccountStatusValueDM.NotCreated::class.java)
}
}
@Nested
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
inner class ConvertBack {
@Test
fun `GIVEN DM Empty WHEN convertBack THEN returns domain Empty`() {
// GIVEN
val dm = PaymentAccountStatusValueDM.Empty()
// WHEN
val result = converter.convertBack(userWalletId, dm)
// THEN
assertThat(result).isEqualTo(PaymentAccountStatusValue.Empty)
}
@Test
fun `GIVEN DM DeactivatedAccount WHEN convertBack THEN returns domain Deactivated with CACHE source`() {
// GIVEN
val dm = PaymentAccountStatusValueDM.DeactivatedAccount(
fiatBalance = PaymentAccountStatusValueDM.FiatBalanceDM(
availableBalance = BigDecimal("200"),
currency = "EUR",
)
)
// WHEN
val result = converter.convertBack(userWalletId, dm)
// THEN
assertThat(result).isInstanceOf(PaymentAccountStatusValue.Deactivated::class.java)
val deactivated = result as PaymentAccountStatusValue.Deactivated
assertThat(deactivated.source).isEqualTo(StatusSource.CACHE)
assertThat(deactivated.fiatBalance.availableBalance).isEqualTo(BigDecimal("200"))
assertThat(deactivated.fiatBalance.currency).isEqualTo("EUR")
}
@Test
fun `GIVEN null DM WHEN convertBack THEN returns Error Unavailable`() {
// GIVEN / WHEN
val result = converter.convertBack(userWalletId, null)
// THEN
assertThat(result).isEqualTo(PaymentAccountStatusValue.Error.Unavailable)
}
@Test
fun `GIVEN DM NotCreated WHEN convertBack THEN returns domain NotCreated`() {
// GIVEN
val dm = PaymentAccountStatusValueDM.NotCreated()
// WHEN
val result = converter.convertBack(userWalletId, dm)
// THEN
assertThat(result).isEqualTo(PaymentAccountStatusValue.NotCreated)
}
}
}

View file

@ -6,8 +6,10 @@ import com.tangem.domain.account.status.supplier.SingleAccountStatusListSupplier
import com.tangem.domain.account.status.usecase.*
import com.tangem.domain.account.status.utils.CryptoCurrencyBalanceFetcher
import com.tangem.domain.account.status.utils.CryptoCurrencyMetadataCleaner
import com.tangem.domain.account.supplier.MultiAccountListSupplier
import com.tangem.domain.account.supplier.SingleAccountListSupplier
import com.tangem.domain.common.wallets.UserWalletsListRepository
import com.tangem.domain.pay.flow.PaymentAccountStatusSupplier
import com.tangem.domain.express.ExpressServiceFetcher
import com.tangem.domain.networks.multi.MultiNetworkStatusFetcher
import com.tangem.domain.networks.multi.MultiNetworkStatusSupplier
@ -51,9 +53,13 @@ internal object AccountStatusUseCaseModule {
@Provides
@Singleton
fun provideIsAccountsModeEnabledUseCase(
multiAccountStatusListSupplier: MultiAccountStatusListSupplier,
multiAccountListSupplier: MultiAccountListSupplier,
paymentAccountStatusSupplier: PaymentAccountStatusSupplier,
): IsAccountsModeEnabledUseCase {
return IsAccountsModeEnabledUseCase(multiAccountStatusListSupplier = multiAccountStatusListSupplier)
return IsAccountsModeEnabledUseCase(
multiAccountListSupplier = multiAccountListSupplier,
paymentAccountStatusSupplier = paymentAccountStatusSupplier,
)
}
@Provides

View file

@ -1,49 +1,76 @@
package com.tangem.domain.account.status.usecase
import com.tangem.domain.account.status.supplier.MultiAccountStatusListSupplier
import com.tangem.domain.models.account.AccountStatus
import com.tangem.domain.account.models.AccountList
import com.tangem.domain.account.supplier.MultiAccountListSupplier
import com.tangem.domain.models.account.Account
import com.tangem.domain.models.account.PaymentAccountStatusValue
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import com.tangem.domain.pay.flow.PaymentAccountStatusProducer
import com.tangem.domain.pay.flow.PaymentAccountStatusSupplier
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.*
/**
* Use case to determine if the accounts mode is enabled.
* Accounts mode is considered enabled if any [com.tangem.domain.account.models.AccountStatusList] produced for the
* user's wallets has more than one [AccountStatus.CryptoPortfolio], or has a [AccountStatus.Payment] with any
* [PaymentAccountStatusValue.Empty].
*
* @property multiAccountStatusListSupplier supplier that provides a list of
* [com.tangem.domain.account.models.AccountStatusList]s for all user wallets
* Accounts mode is considered enabled if any user wallet either:
* - contains more than one [Account.CryptoPortfolio], or
* - has a [com.tangem.domain.models.account.AccountStatus.Payment] in an active state.
*
[REDACTED_AUTHOR]
* Splits the structural check (via [MultiAccountListSupplier]) and the payment-status check
* (via [PaymentAccountStatusSupplier] per wallet) so that the structural answer doesn't wait for
* slow per-wallet status pipelines on cold start.
*/
class IsAccountsModeEnabledUseCase(
private val multiAccountStatusListSupplier: MultiAccountStatusListSupplier,
private val multiAccountListSupplier: MultiAccountListSupplier,
private val paymentAccountStatusSupplier: PaymentAccountStatusSupplier,
) {
@OptIn(ExperimentalCoroutinesApi::class)
operator fun invoke(): Flow<Boolean> {
return multiAccountStatusListSupplier.invoke()
.map { accountStatusLists -> accountStatusLists.any { it.accountStatuses.isModeEnabled() } }
val cryptoMode = multiAccountListSupplier.invoke()
.map { lists -> lists.any { it.hasMultipleCryptoPortfolios() } }
val paymentMode = multiAccountListSupplier.invoke().flatMapLatest { lists ->
val walletIdsWithPayment = lists.mapNotNull { list ->
if (list.accounts.any { it is Account.Payment }) list.userWalletId else null
}
if (walletIdsWithPayment.isEmpty()) {
flowOf(false)
} else {
val flows = walletIdsWithPayment.map { walletId ->
paymentAccountStatusSupplier.invoke(walletId)
.map { it.value.isActivePayment() }
.onStart { emit(false) }
}
combine(flows) { results -> results.any { it } }
}
}
return combine(cryptoMode, paymentMode) { crypto, payment -> crypto || payment }
.distinctUntilChanged()
}
suspend fun invokeSync(): Boolean {
return multiAccountStatusListSupplier.getSyncOrNull(Unit)
?.any { it.accountStatuses.isModeEnabled() } == true
val accountLists = multiAccountListSupplier.getSyncOrNull(Unit).orEmpty()
if (accountLists.any { it.hasMultipleCryptoPortfolios() }) return true
val walletIdsWithPayment = accountLists.mapNotNull { list ->
if (list.accounts.any { it is Account.Payment }) list.userWalletId else null
}
return walletIdsWithPayment.any { walletId ->
paymentAccountStatusSupplier
.getSyncOrNull(
params = PaymentAccountStatusProducer.Params(walletId),
timeMillis = PAYMENT_STATUS_SYNC_TIMEOUT_MS,
)
?.value
?.isActivePayment() == true
}
}
private fun List<AccountStatus>.isModeEnabled(): Boolean {
var cryptoPortfolioCount = 0
for (status in this) {
when {
status is AccountStatus.CryptoPortfolio && ++cryptoPortfolioCount > 1 -> return true
status is AccountStatus.Payment && status.value.isActivePayment() -> return true
}
}
return false
}
private fun AccountList.hasMultipleCryptoPortfolios(): Boolean =
accounts.filterIsInstance<Account.CryptoPortfolio>().size > 1
private fun PaymentAccountStatusValue.isActivePayment(): Boolean {
return when (this) {
@ -55,7 +82,12 @@ class IsAccountsModeEnabledUseCase(
is PaymentAccountStatusValue.Loaded,
is PaymentAccountStatusValue.Loading,
is PaymentAccountStatusValue.UnderReview,
is PaymentAccountStatusValue.Deactivated,
-> true
}
}
private companion object {
const val PAYMENT_STATUS_SYNC_TIMEOUT_MS = 1_000L
}
}

View file

@ -1,16 +1,20 @@
package com.tangem.domain.account.status.usecase
import com.google.common.truth.Truth
import com.tangem.domain.account.models.AccountStatusList
import com.tangem.domain.account.status.supplier.MultiAccountStatusListSupplier
import com.tangem.domain.account.models.AccountList
import com.tangem.domain.account.supplier.MultiAccountListSupplier
import com.tangem.domain.models.account.Account
import com.tangem.domain.models.account.AccountStatus
import com.tangem.domain.models.account.PaymentAccountStatusValue
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.pay.flow.PaymentAccountStatusProducer
import com.tangem.domain.pay.flow.PaymentAccountStatusSupplier
import io.mockk.clearMocks
import io.mockk.coEvery
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.last
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Nested
@ -20,13 +24,17 @@ import org.junit.jupiter.api.TestInstance
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class IsAccountsModeEnabledUseCaseTest {
private val multiAccountStatusListSupplier: MultiAccountStatusListSupplier = mockk()
private val multiAccountListSupplier: MultiAccountListSupplier = mockk()
private val paymentAccountStatusSupplier: PaymentAccountStatusSupplier = mockk()
private val useCase = IsAccountsModeEnabledUseCase(multiAccountStatusListSupplier = multiAccountStatusListSupplier)
private val useCase = IsAccountsModeEnabledUseCase(
multiAccountListSupplier = multiAccountListSupplier,
paymentAccountStatusSupplier = paymentAccountStatusSupplier,
)
@AfterEach
fun tearDown() {
clearMocks(multiAccountStatusListSupplier)
clearMocks(multiAccountListSupplier, paymentAccountStatusSupplier)
}
@Nested
@ -35,118 +43,129 @@ class IsAccountsModeEnabledUseCaseTest {
@Test
fun `returns false when supplier emits empty list`() = runTest {
every { multiAccountStatusListSupplier.invoke() } returns flowOf(emptyList())
every { multiAccountListSupplier.invoke() } returns flowOf(emptyList())
val actual = useCase.invoke().first()
val actual = useCase.invoke().last()
Truth.assertThat(actual).isFalse()
}
@Test
fun `returns false when single crypto portfolio account`() = runTest {
val statusList = createAccountStatusList(
statuses = listOf(mockCryptoPortfolio()),
)
every { multiAccountStatusListSupplier.invoke() } returns flowOf(listOf(statusList))
val list = createAccountList(WALLET_ID_1, listOf(mockCryptoPortfolio()))
every { multiAccountListSupplier.invoke() } returns flowOf(listOf(list))
val actual = useCase.invoke().first()
val actual = useCase.invoke().last()
Truth.assertThat(actual).isFalse()
}
@Test
fun `returns true when two crypto portfolio accounts`() = runTest {
val statusList = createAccountStatusList(
statuses = listOf(mockCryptoPortfolio(), mockCryptoPortfolio()),
)
every { multiAccountStatusListSupplier.invoke() } returns flowOf(listOf(statusList))
val list = createAccountList(WALLET_ID_1, listOf(mockCryptoPortfolio(), mockCryptoPortfolio()))
every { multiAccountListSupplier.invoke() } returns flowOf(listOf(list))
val actual = useCase.invoke().first()
val actual = useCase.invoke().last()
Truth.assertThat(actual).isTrue()
}
@Test
fun `returns true when payment account is Loaded`() = runTest {
val statusList = createAccountStatusList(
statuses = listOf(mockCryptoPortfolio(), mockPayment(mockk<PaymentAccountStatusValue.Loaded>())),
)
every { multiAccountStatusListSupplier.invoke() } returns flowOf(listOf(statusList))
val list = createAccountList(WALLET_ID_1, listOf(mockCryptoPortfolio(), mockPaymentAccount()))
every { multiAccountListSupplier.invoke() } returns flowOf(listOf(list))
mockPaymentStatus(WALLET_ID_1, mockk<PaymentAccountStatusValue.Loaded>())
val actual = useCase.invoke().first()
val actual = useCase.invoke().last()
Truth.assertThat(actual).isTrue()
}
@Test
fun `returns false when payment account is NotCreated`() = runTest {
val statusList = createAccountStatusList(
statuses = listOf(mockCryptoPortfolio(), mockPayment(PaymentAccountStatusValue.NotCreated)),
)
every { multiAccountStatusListSupplier.invoke() } returns flowOf(listOf(statusList))
val list = createAccountList(WALLET_ID_1, listOf(mockCryptoPortfolio(), mockPaymentAccount()))
every { multiAccountListSupplier.invoke() } returns flowOf(listOf(list))
mockPaymentStatus(WALLET_ID_1, PaymentAccountStatusValue.NotCreated)
val actual = useCase.invoke().first()
val actual = useCase.invoke().last()
Truth.assertThat(actual).isFalse()
}
@Test
fun `returns false when payment account is Empty`() = runTest {
val statusList = createAccountStatusList(
statuses = listOf(mockCryptoPortfolio(), mockPayment(PaymentAccountStatusValue.Empty)),
)
every { multiAccountStatusListSupplier.invoke() } returns flowOf(listOf(statusList))
val list = createAccountList(WALLET_ID_1, listOf(mockCryptoPortfolio(), mockPaymentAccount()))
every { multiAccountListSupplier.invoke() } returns flowOf(listOf(list))
mockPaymentStatus(WALLET_ID_1, PaymentAccountStatusValue.Empty)
val actual = useCase.invoke().first()
val actual = useCase.invoke().last()
Truth.assertThat(actual).isFalse()
}
@Test
fun `returns true when payment account is UnderReview`() = runTest {
val statusList = createAccountStatusList(
statuses = listOf(mockCryptoPortfolio(), mockPayment(mockk<PaymentAccountStatusValue.UnderReview>())),
)
every { multiAccountStatusListSupplier.invoke() } returns flowOf(listOf(statusList))
val list = createAccountList(WALLET_ID_1, listOf(mockCryptoPortfolio(), mockPaymentAccount()))
every { multiAccountListSupplier.invoke() } returns flowOf(listOf(list))
mockPaymentStatus(WALLET_ID_1, mockk<PaymentAccountStatusValue.UnderReview>())
val actual = useCase.invoke().first()
val actual = useCase.invoke().last()
Truth.assertThat(actual).isTrue()
}
@Test
fun `returns true when payment account is IssuingCard`() = runTest {
val statusList = createAccountStatusList(
statuses = listOf(mockCryptoPortfolio(), mockPayment(mockk<PaymentAccountStatusValue.IssuingCard>())),
)
every { multiAccountStatusListSupplier.invoke() } returns flowOf(listOf(statusList))
val list = createAccountList(WALLET_ID_1, listOf(mockCryptoPortfolio(), mockPaymentAccount()))
every { multiAccountListSupplier.invoke() } returns flowOf(listOf(list))
mockPaymentStatus(WALLET_ID_1, mockk<PaymentAccountStatusValue.IssuingCard>())
val actual = useCase.invoke().first()
val actual = useCase.invoke().last()
Truth.assertThat(actual).isTrue()
}
@Test
fun `returns true when payment account is Loading`() = runTest {
val statusList = createAccountStatusList(
statuses = listOf(mockCryptoPortfolio(), mockPayment(PaymentAccountStatusValue.Loading)),
)
every { multiAccountStatusListSupplier.invoke() } returns flowOf(listOf(statusList))
val list = createAccountList(WALLET_ID_1, listOf(mockCryptoPortfolio(), mockPaymentAccount()))
every { multiAccountListSupplier.invoke() } returns flowOf(listOf(list))
mockPaymentStatus(WALLET_ID_1, PaymentAccountStatusValue.Loading)
val actual = useCase.invoke().first()
val actual = useCase.invoke().last()
Truth.assertThat(actual).isTrue()
}
@Test
fun `returns true when multiple status lists and one has two crypto portfolios`() = runTest {
val statusList1 = createAccountStatusList(statuses = listOf(mockCryptoPortfolio()))
val statusList2 = createAccountStatusList(
statuses = listOf(mockCryptoPortfolio(), mockCryptoPortfolio()),
)
every { multiAccountStatusListSupplier.invoke() } returns flowOf(listOf(statusList1, statusList2))
fun `returns true when payment account is Deactivated`() = runTest {
val list = createAccountList(WALLET_ID_1, listOf(mockCryptoPortfolio(), mockPaymentAccount()))
every { multiAccountListSupplier.invoke() } returns flowOf(listOf(list))
mockPaymentStatus(WALLET_ID_1, mockk<PaymentAccountStatusValue.Deactivated>())
val actual = useCase.invoke().first()
val actual = useCase.invoke().last()
Truth.assertThat(actual).isTrue()
}
@Test
fun `returns true when multiple wallets and one has two crypto portfolios`() = runTest {
val list1 = createAccountList(WALLET_ID_1, listOf(mockCryptoPortfolio()))
val list2 = createAccountList(WALLET_ID_2, listOf(mockCryptoPortfolio(), mockCryptoPortfolio()))
every { multiAccountListSupplier.invoke() } returns flowOf(listOf(list1, list2))
val actual = useCase.invoke().last()
Truth.assertThat(actual).isTrue()
}
@Test
fun `returns true when multiple wallets and one has active payment`() = runTest {
val list1 = createAccountList(WALLET_ID_1, listOf(mockCryptoPortfolio()))
val list2 = createAccountList(WALLET_ID_2, listOf(mockCryptoPortfolio(), mockPaymentAccount()))
every { multiAccountListSupplier.invoke() } returns flowOf(listOf(list1, list2))
mockPaymentStatus(WALLET_ID_2, mockk<PaymentAccountStatusValue.Loaded>())
val actual = useCase.invoke().last()
Truth.assertThat(actual).isTrue()
}
@ -158,7 +177,7 @@ class IsAccountsModeEnabledUseCaseTest {
@Test
fun `returns false when getSyncOrNull returns null`() = runTest {
coEvery { multiAccountStatusListSupplier.getSyncOrNull(Unit, any()) } returns null
coEvery { multiAccountListSupplier.getSyncOrNull(Unit, any()) } returns null
val actual = useCase.invokeSync()
@ -167,7 +186,7 @@ class IsAccountsModeEnabledUseCaseTest {
@Test
fun `returns false when getSyncOrNull returns empty list`() = runTest {
coEvery { multiAccountStatusListSupplier.getSyncOrNull(Unit, any()) } returns emptyList()
coEvery { multiAccountListSupplier.getSyncOrNull(Unit, any()) } returns emptyList()
val actual = useCase.invokeSync()
@ -176,10 +195,8 @@ class IsAccountsModeEnabledUseCaseTest {
@Test
fun `returns false when single crypto portfolio account`() = runTest {
val statusList = createAccountStatusList(
statuses = listOf(mockCryptoPortfolio()),
)
coEvery { multiAccountStatusListSupplier.getSyncOrNull(Unit, any()) } returns listOf(statusList)
val list = createAccountList(WALLET_ID_1, listOf(mockCryptoPortfolio()))
coEvery { multiAccountListSupplier.getSyncOrNull(Unit, any()) } returns listOf(list)
val actual = useCase.invokeSync()
@ -188,10 +205,8 @@ class IsAccountsModeEnabledUseCaseTest {
@Test
fun `returns true when two crypto portfolio accounts`() = runTest {
val statusList = createAccountStatusList(
statuses = listOf(mockCryptoPortfolio(), mockCryptoPortfolio()),
)
coEvery { multiAccountStatusListSupplier.getSyncOrNull(Unit, any()) } returns listOf(statusList)
val list = createAccountList(WALLET_ID_1, listOf(mockCryptoPortfolio(), mockCryptoPortfolio()))
coEvery { multiAccountListSupplier.getSyncOrNull(Unit, any()) } returns listOf(list)
val actual = useCase.invokeSync()
@ -200,10 +215,9 @@ class IsAccountsModeEnabledUseCaseTest {
@Test
fun `returns true when payment account is Loaded`() = runTest {
val statusList = createAccountStatusList(
statuses = listOf(mockCryptoPortfolio(), mockPayment(mockk<PaymentAccountStatusValue.Loaded>())),
)
coEvery { multiAccountStatusListSupplier.getSyncOrNull(Unit, any()) } returns listOf(statusList)
val list = createAccountList(WALLET_ID_1, listOf(mockCryptoPortfolio(), mockPaymentAccount()))
coEvery { multiAccountListSupplier.getSyncOrNull(Unit, any()) } returns listOf(list)
mockPaymentStatusSync(WALLET_ID_1, mockk<PaymentAccountStatusValue.Loaded>())
val actual = useCase.invokeSync()
@ -212,10 +226,9 @@ class IsAccountsModeEnabledUseCaseTest {
@Test
fun `returns false when payment account is NotCreated`() = runTest {
val statusList = createAccountStatusList(
statuses = listOf(mockCryptoPortfolio(), mockPayment(PaymentAccountStatusValue.NotCreated)),
)
coEvery { multiAccountStatusListSupplier.getSyncOrNull(Unit, any()) } returns listOf(statusList)
val list = createAccountList(WALLET_ID_1, listOf(mockCryptoPortfolio(), mockPaymentAccount()))
coEvery { multiAccountListSupplier.getSyncOrNull(Unit, any()) } returns listOf(list)
mockPaymentStatusSync(WALLET_ID_1, PaymentAccountStatusValue.NotCreated)
val actual = useCase.invokeSync()
@ -224,10 +237,9 @@ class IsAccountsModeEnabledUseCaseTest {
@Test
fun `returns false when payment account is Empty`() = runTest {
val statusList = createAccountStatusList(
statuses = listOf(mockCryptoPortfolio(), mockPayment(PaymentAccountStatusValue.Empty)),
)
coEvery { multiAccountStatusListSupplier.getSyncOrNull(Unit, any()) } returns listOf(statusList)
val list = createAccountList(WALLET_ID_1, listOf(mockCryptoPortfolio(), mockPaymentAccount()))
coEvery { multiAccountListSupplier.getSyncOrNull(Unit, any()) } returns listOf(list)
mockPaymentStatusSync(WALLET_ID_1, PaymentAccountStatusValue.Empty)
val actual = useCase.invokeSync()
@ -236,10 +248,9 @@ class IsAccountsModeEnabledUseCaseTest {
@Test
fun `returns true when payment account is UnderReview`() = runTest {
val statusList = createAccountStatusList(
statuses = listOf(mockCryptoPortfolio(), mockPayment(mockk<PaymentAccountStatusValue.UnderReview>())),
)
coEvery { multiAccountStatusListSupplier.getSyncOrNull(Unit, any()) } returns listOf(statusList)
val list = createAccountList(WALLET_ID_1, listOf(mockCryptoPortfolio(), mockPaymentAccount()))
coEvery { multiAccountListSupplier.getSyncOrNull(Unit, any()) } returns listOf(list)
mockPaymentStatusSync(WALLET_ID_1, mockk<PaymentAccountStatusValue.UnderReview>())
val actual = useCase.invokeSync()
@ -247,12 +258,22 @@ class IsAccountsModeEnabledUseCaseTest {
}
@Test
fun `returns true when multiple status lists and one has loaded payment`() = runTest {
val statusList1 = createAccountStatusList(statuses = listOf(mockCryptoPortfolio()))
val statusList2 = createAccountStatusList(
statuses = listOf(mockCryptoPortfolio(), mockPayment(mockk<PaymentAccountStatusValue.Loaded>())),
)
coEvery { multiAccountStatusListSupplier.getSyncOrNull(Unit, any()) } returns listOf(statusList1, statusList2)
fun `returns true when payment account is Deactivated`() = runTest {
val list = createAccountList(WALLET_ID_1, listOf(mockCryptoPortfolio(), mockPaymentAccount()))
coEvery { multiAccountListSupplier.getSyncOrNull(Unit, any()) } returns listOf(list)
mockPaymentStatusSync(WALLET_ID_1, mockk<PaymentAccountStatusValue.Deactivated>())
val actual = useCase.invokeSync()
Truth.assertThat(actual).isTrue()
}
@Test
fun `returns true when multiple wallets and one has loaded payment`() = runTest {
val list1 = createAccountList(WALLET_ID_1, listOf(mockCryptoPortfolio()))
val list2 = createAccountList(WALLET_ID_2, listOf(mockCryptoPortfolio(), mockPaymentAccount()))
coEvery { multiAccountListSupplier.getSyncOrNull(Unit, any()) } returns listOf(list1, list2)
mockPaymentStatusSync(WALLET_ID_2, mockk<PaymentAccountStatusValue.Loaded>())
val actual = useCase.invokeSync()
@ -260,13 +281,29 @@ class IsAccountsModeEnabledUseCaseTest {
}
}
private fun mockCryptoPortfolio(): AccountStatus.CryptoPortfolio = mockk()
private fun mockCryptoPortfolio(): Account.CryptoPortfolio = mockk()
private fun mockPayment(value: PaymentAccountStatusValue): AccountStatus.Payment = mockk {
every { this@mockk.value } returns value
private fun mockPaymentAccount(): Account.Payment = mockk()
private fun mockPaymentStatus(walletId: UserWalletId, value: PaymentAccountStatusValue) {
val status = mockk<AccountStatus.Payment> { every { this@mockk.value } returns value }
every { paymentAccountStatusSupplier.invoke(walletId) } returns flowOf(status)
}
private fun createAccountStatusList(statuses: List<AccountStatus>): AccountStatusList = mockk {
every { accountStatuses } returns statuses
private fun mockPaymentStatusSync(walletId: UserWalletId, value: PaymentAccountStatusValue) {
val status = mockk<AccountStatus.Payment> { every { this@mockk.value } returns value }
coEvery {
paymentAccountStatusSupplier.getSyncOrNull(PaymentAccountStatusProducer.Params(walletId), any())
} returns status
}
private fun createAccountList(walletId: UserWalletId, accounts: List<Account>): AccountList = mockk {
every { userWalletId } returns walletId
every { this@mockk.accounts } returns accounts
}
private companion object {
val WALLET_ID_1 = UserWalletId(stringValue = "011")
val WALLET_ID_2 = UserWalletId(stringValue = "022")
}
}

View file

@ -20,7 +20,6 @@ interface ScanCardProcessor {
cardId: String? = null,
onProgressStateChange: suspend (showProgress: Boolean) -> Unit = {},
onWalletNotCreated: suspend () -> Unit = {},
disclaimerWillShow: () -> Unit = {},
onCancel: suspend () -> Unit = {},
onFailure: suspend (error: TangemError) -> Unit = {},
onSuccess: suspend (scanResponse: ScanResponse) -> Unit = {},

View file

@ -32,6 +32,7 @@ sealed class PaymentAccountStatusValue {
-> TotalFiatBalance.Loaded(amount = SerializedBigDecimal.ZERO, source = source)
is Loading -> TotalFiatBalance.Loading
is Loaded -> TotalFiatBalance.Loaded(amount = fiatBalance.availableBalance, source = source)
is Deactivated -> TotalFiatBalance.Loaded(amount = fiatBalance.availableBalance, source = source)
}
/**
@ -44,6 +45,7 @@ sealed class PaymentAccountStatusValue {
is IssuingCard -> copy(source = source)
is Loaded -> copy(source = source)
is UnderReview -> copy(source = source)
is Deactivated -> copy(source = source)
is Loading,
is Empty,
is NotCreated,
@ -92,6 +94,18 @@ sealed class PaymentAccountStatusValue {
@Serializable
data class IssuingCard(override val source: StatusSource) : PaymentAccountStatusValue()
/**
* Represents a state where the account is deactivated.
*
* @property source The source of the status information.
* @property fiatBalance The fiat balance details.
*/
@Serializable
data class Deactivated(
override val source: StatusSource,
val fiatBalance: FiatBalance,
) : PaymentAccountStatusValue()
/**
* Represents a state where the payment account is successfully loaded with complete information.
*

View file

@ -12,5 +12,6 @@ data class TangemPayDetailsConfig(
val cardFrozenState: TangemPayCardFrozenState,
val cardNumberEnd: String,
val chainId: Int,
val isTangemPayDeactivated: Boolean,
val displayName: CardDisplayName?,
)

View file

@ -57,7 +57,6 @@ sealed class VisaApiError(
data object CustomerIsBlocked : VisaApiError(104110210)
data object UnknownWithoutCode : VisaApiError(104110999)
data class Unknown(override val errorCode: Int) : VisaApiError(errorCode)
data object Deactivated : VisaApiError(0)
fun isUnknown() = this is UnknownWithoutCode || this is Unknown

View file

@ -25,6 +25,8 @@ data class CustomerInfo(
val productInstance: ProductInstance?,
val kycStatus: KycStatus,
val cardInfo: CardInfo?,
val state: State,
val fiatBalance: PaymentAccountStatusValue.FiatBalance?,
) {
enum class State {
NEW,
@ -54,7 +56,23 @@ data class CustomerInfo(
val displayName: CardDisplayName?,
val actualCardLimit: TangemPayCardLimit?,
val adminCardLimit: TangemPayCardLimit?,
)
val status: Status,
) {
enum class Status {
NEW,
READY_FOR_MANUFACTURING,
MANUFACTURING,
SENT_TO_DELIVERY,
DELIVERED,
ACTIVATING,
ACTIVE,
BLOCKED,
DEACTIVATING,
DEACTIVATED,
CANCELED,
UNKNOWN,
}
}
data class CardInfo(
val lastFourDigits: String,

View file

@ -6,5 +6,4 @@ sealed interface TangemPayCustomerInfoError {
data object RefreshNeededError : TangemPayCustomerInfoError
data object UnknownError : TangemPayCustomerInfoError
data object ExposedDeviceError : TangemPayCustomerInfoError
data object DeactivatedError : TangemPayCustomerInfoError
}

View file

@ -26,8 +26,8 @@ import com.tangem.domain.models.tokenlist.TokenList
import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenBridgeInternal.SearchQuery
import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenBridgeInternal.SearchQuery.Companion.isSearchingState
import com.tangem.features.commonfeatures.api.choosetoken.model.TokenListUMData
import com.tangem.features.commonfeatures.impl.choosetoken.model.ClickIntents
import com.tangem.features.commonfeatures.impl.R
import com.tangem.features.commonfeatures.impl.choosetoken.model.ClickIntents
import kotlinx.collections.immutable.toPersistentList
internal class ChooseTokenListItemConverter(
@ -187,6 +187,7 @@ internal class ChooseTokenListItemConverter(
is PaymentAccountStatusValue.UnderReview,
PaymentAccountStatusValue.Loading,
PaymentAccountStatusValue.Empty,
is PaymentAccountStatusValue.Deactivated,
-> return null
is PaymentAccountStatusValue.Loaded -> status.cryptoCurrencyStatus
}

View file

@ -88,7 +88,6 @@ internal class CreateWalletStartModelTest {
cardId = any(),
onProgressStateChange = any(),
onWalletNotCreated = any(),
disclaimerWillShow = any(),
onCancel = any(),
onFailure = any(),
onSuccess = any()
@ -118,7 +117,6 @@ internal class CreateWalletStartModelTest {
cardId = null,
onProgressStateChange = any(),
onWalletNotCreated = any(),
disclaimerWillShow = any(),
onCancel = any(),
onFailure = any(),
onSuccess = any()
@ -148,7 +146,6 @@ internal class CreateWalletStartModelTest {
cardId = null,
onProgressStateChange = any(),
onWalletNotCreated = any(),
disclaimerWillShow = any(),
onCancel = any(),
onFailure = any(),
onSuccess = any()
@ -266,7 +263,6 @@ internal class CreateWalletStartModelTest {
cardId = null,
onProgressStateChange = any(),
onWalletNotCreated = any(),
disclaimerWillShow = any(),
onCancel = any(),
onFailure = any(),
onSuccess = any()
@ -284,13 +280,12 @@ internal class CreateWalletStartModelTest {
cardId = any(),
onProgressStateChange = any(),
onWalletNotCreated = any(),
disclaimerWillShow = any(),
onCancel = any(),
onFailure = any(),
onSuccess = any()
)
} coAnswers {
val onFailure = arg<suspend (TangemError) -> Unit>(7)
val onFailure = arg<suspend (TangemError) -> Unit>(6)
onFailure.invoke(error)
}
@ -313,13 +308,12 @@ internal class CreateWalletStartModelTest {
cardId = any(),
onProgressStateChange = any(),
onWalletNotCreated = any(),
disclaimerWillShow = any(),
onCancel = any(),
onFailure = any(),
onSuccess = any()
)
} coAnswers {
val onFailure = arg<suspend (TangemError) -> Unit>(7)
val onFailure = arg<suspend (TangemError) -> Unit>(6)
onFailure.invoke(error)
}
@ -342,13 +336,12 @@ internal class CreateWalletStartModelTest {
cardId = any(),
onProgressStateChange = any(),
onWalletNotCreated = any(),
disclaimerWillShow = any(),
onCancel = any(),
onFailure = any(),
onSuccess = any()
)
} coAnswers {
val onFailure = arg<suspend (TangemError) -> Unit>(7)
val onFailure = arg<suspend (TangemError) -> Unit>(6)
onFailure.invoke(error)
}
@ -371,13 +364,12 @@ internal class CreateWalletStartModelTest {
cardId = any(),
onProgressStateChange = any(),
onWalletNotCreated = any(),
disclaimerWillShow = any(),
onCancel = any(),
onFailure = any(),
onSuccess = any()
)
} coAnswers {
val onSuccess = arg<suspend (ScanResponse) -> Unit>(8)
val onSuccess = arg<suspend (ScanResponse) -> Unit>(7)
onSuccess.invoke(testScanResponse)
}
@ -411,13 +403,12 @@ internal class CreateWalletStartModelTest {
cardId = any(),
onProgressStateChange = any(),
onWalletNotCreated = any(),
disclaimerWillShow = any(),
onCancel = any(),
onFailure = any(),
onSuccess = any()
)
} coAnswers {
val onSuccess = arg<suspend (ScanResponse) -> Unit>(8)
val onSuccess = arg<suspend (ScanResponse) -> Unit>(7)
onSuccess.invoke(testScanResponse)
}
@ -448,13 +439,12 @@ internal class CreateWalletStartModelTest {
cardId = any(),
onProgressStateChange = any(),
onWalletNotCreated = any(),
disclaimerWillShow = any(),
onCancel = any(),
onFailure = any(),
onSuccess = any()
)
} coAnswers {
val onSuccess = arg<suspend (ScanResponse) -> Unit>(8)
val onSuccess = arg<suspend (ScanResponse) -> Unit>(7)
onSuccess.invoke(testScanResponse)
}
@ -482,13 +472,12 @@ internal class CreateWalletStartModelTest {
cardId = any(),
onProgressStateChange = any(),
onWalletNotCreated = any(),
disclaimerWillShow = any(),
onCancel = any(),
onFailure = any(),
onSuccess = any()
)
} coAnswers {
val onSuccess = arg<suspend (ScanResponse) -> Unit>(8)
val onSuccess = arg<suspend (ScanResponse) -> Unit>(7)
onSuccess.invoke(testScanResponse)
}
@ -515,13 +504,12 @@ internal class CreateWalletStartModelTest {
cardId = any(),
onProgressStateChange = any(),
onWalletNotCreated = any(),
disclaimerWillShow = any(),
onCancel = any(),
onFailure = any(),
onSuccess = any()
)
} coAnswers {
val onSuccess = arg<suspend (ScanResponse) -> Unit>(8)
val onSuccess = arg<suspend (ScanResponse) -> Unit>(7)
onSuccess.invoke(testScanResponse)
}

View file

@ -133,10 +133,6 @@ internal class UserWalletSaver @Inject constructor(
onWalletNotCreated = {
continuation.resume(Either.Right(null))
},
disclaimerWillShow = {
continuation.resume(Either.Right(null))
router.pop()
},
onSuccess = {
continuation.resume(Either.Right(it))
},

View file

@ -3,12 +3,14 @@ package com.tangem.features.tangempay.entity
import com.tangem.core.ui.components.buttons.actions.ActionButtonConfig
import com.tangem.core.ui.components.containers.pullToRefresh.PullToRefreshConfig
import com.tangem.core.ui.components.dropdownmenu.TangemDropdownMenuItem
import com.tangem.core.ui.components.notifications.NotificationConfig
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.themedColor
import com.tangem.core.ui.res.TangemTheme
import com.tangem.domain.visa.model.TangemPayCardFrozenState
import com.tangem.features.tangempay.details.impl.R
import com.tangem.features.tangempay.utils.TangemPayDetailIntents
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
@Suppress("LongParameterList")
@ -18,25 +20,13 @@ internal class TangemPayDetailsStateFactory(
private val intents: TangemPayDetailIntents,
private val cardFrozenState: TangemPayCardFrozenState,
) {
@Suppress("LongMethod")
fun getInitialState(cardNumberEnd: String): TangemPayDetailsUM {
fun getInitialState(isTangemPayDeactivated: Boolean, cardNumberEnd: String): TangemPayDetailsUM {
return TangemPayDetailsUM(
topBarConfig = TangemPayDetailsTopBarConfig(
onBackClick = onBack,
onOpenMenu = onOpenMenu,
items = persistentListOf(
TangemDropdownMenuItem(
title = resourceReference(R.string.tangem_pay_terms_limits),
textColor = themedColor { TangemTheme.colors.text.primary1 },
onClick = intents::onClickTermsAndLimits,
),
TangemDropdownMenuItem(
title = resourceReference(R.string.tangempay_pay_support),
textColor = themedColor { TangemTheme.colors.text.primary1 },
onClick = intents::onContactSupportClicked,
),
),
items = getTopBarMenuItems(isTangemPayDeactivated),
),
pullToRefreshConfig = PullToRefreshConfig(
isRefreshing = false,
@ -69,6 +59,28 @@ internal class TangemPayDetailsStateFactory(
),
isBalanceHidden = false,
addFundsEnabled = true,
accountDeactivatedNotificationConfig = NotificationConfig(
title = resourceReference(R.string.tangempay_account_deactivated_message_title),
subtitle = resourceReference(R.string.tangempay_account_deactivated_message_subtitle),
iconResId = R.drawable.img_attention_20,
).takeIf { isTangemPayDeactivated },
)
}
private fun getTopBarMenuItems(isTangemPayDeactivated: Boolean): ImmutableList<TangemDropdownMenuItem> {
if (isTangemPayDeactivated) return persistentListOf()
return persistentListOf(
TangemDropdownMenuItem(
title = resourceReference(R.string.tangem_pay_terms_limits),
textColor = themedColor { TangemTheme.colors.text.primary1 },
onClick = intents::onClickTermsAndLimits,
),
TangemDropdownMenuItem(
title = resourceReference(R.string.tangempay_pay_support),
textColor = themedColor { TangemTheme.colors.text.primary1 },
onClick = intents::onContactSupportClicked,
),
)
}
}

View file

@ -2,6 +2,7 @@ package com.tangem.features.tangempay.entity
import com.tangem.core.ui.components.buttons.actions.ActionButtonConfig
import com.tangem.core.ui.components.containers.pullToRefresh.PullToRefreshConfig
import com.tangem.core.ui.components.notifications.NotificationConfig
import com.tangem.core.ui.extensions.TextReference
import com.tangem.domain.visa.model.TangemPayCardFrozenState
import com.tangem.features.tangempay.model.CardDataType
@ -13,6 +14,7 @@ internal data class TangemPayDetailsUM(
val balanceBlockState: TangemPayDetailsBalanceBlockState,
val isBalanceHidden: Boolean,
val addFundsEnabled: Boolean,
val accountDeactivatedNotificationConfig: NotificationConfig?,
)
internal data class TangemPayCardDetailsUM(

View file

@ -56,10 +56,7 @@ import com.tangem.utils.coroutines.saveIn
import com.tangem.utils.logging.TangemLogger
import com.tangem.utils.transformer.update
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import javax.inject.Inject
@ -94,7 +91,12 @@ internal class TangemPayDetailsModel @Inject constructor(
)
val uiState: StateFlow<TangemPayDetailsUM>
field = MutableStateFlow(stateFactory.getInitialState(cardNumberEnd = params.config.cardNumberEnd))
field = MutableStateFlow(
stateFactory.getInitialState(
isTangemPayDeactivated = params.config.isTangemPayDeactivated,
cardNumberEnd = params.config.cardNumberEnd,
),
)
private val refreshStateJobHolder = JobHolder()
private val fetchBalanceJobHolder = JobHolder()
@ -112,7 +114,9 @@ internal class TangemPayDetailsModel @Inject constructor(
analytics.send(TangemPayAnalyticsEvents.MainScreenOpened())
handleBalanceHiding()
fetchBalance()
subscribeToCardFrozenState()
if (!params.config.isTangemPayDeactivated) {
subscribeToCardFrozenState()
}
}
fun onResume() {

View file

@ -34,6 +34,7 @@ import androidx.compose.ui.unit.sp
import androidx.compose.ui.util.fastForEach
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.tangem.core.ui.components.RectangleShimmer
import com.tangem.core.ui.components.SpacerH12
import com.tangem.core.ui.components.buttons.HorizontalActionChips
import com.tangem.core.ui.components.buttons.actions.ActionButtonConfig
import com.tangem.core.ui.components.buttons.small.TangemIconButton
@ -41,6 +42,7 @@ import com.tangem.core.ui.components.containers.pullToRefresh.PullToRefreshConfi
import com.tangem.core.ui.components.containers.pullToRefresh.TangemPullToRefreshContainer
import com.tangem.core.ui.components.dropdownmenu.TangemDropdownItem
import com.tangem.core.ui.components.dropdownmenu.TangemDropdownMenu
import com.tangem.core.ui.components.notifications.Notification
import com.tangem.core.ui.components.text.applyBladeBrush
import com.tangem.core.ui.extensions.orMaskWithStars
import com.tangem.core.ui.extensions.resourceReference
@ -106,22 +108,38 @@ internal fun TangemPayDetailsScreen(
TangemPayDetailsBalanceBlock(
modifier = Modifier
.padding(horizontal = TangemTheme.dimens.spacing16)
.padding(top = 12.dp)
.fillMaxWidth(),
state = state.balanceBlockState,
isBalanceHidden = state.isBalanceHidden,
)
SpacerH12()
},
)
with(expressTransactionsComponent) {
expressTransactionsContent(
state = expressState.transactionsToDisplay,
modifier = modifier
.padding(start = 16.dp, end = 16.dp, top = 12.dp)
.fillMaxWidth(),
if (state.accountDeactivatedNotificationConfig != null) {
item(
key = "DEACTIVATION_MESSAGE",
content = {
Notification(
modifier = modifier
.padding(horizontal = TangemTheme.dimens.spacing16)
.fillMaxWidth(),
config = state.accountDeactivatedNotificationConfig,
)
SpacerH12()
},
)
}
with(txHistoryComponent) { txHistoryContent(listState = listState, state = txHistoryState) }
if (state.accountDeactivatedNotificationConfig == null) {
with(expressTransactionsComponent) {
expressTransactionsContent(
state = expressState.transactionsToDisplay,
modifier = modifier
.padding(horizontal = 16.dp)
.fillMaxWidth(),
)
}
with(txHistoryComponent) { txHistoryContent(listState = listState, state = txHistoryState) }
}
}
}
expressTransactionsBottomSheetState?.content()
@ -401,6 +419,7 @@ private class TangemPayDetailsUMProvider : CollectionPreviewParameterProvider<Ta
),
isBalanceHidden = false,
addFundsEnabled = true,
accountDeactivatedNotificationConfig = null,
),
TangemPayDetailsUM(
topBarConfig = TangemPayDetailsTopBarConfig(onBackClick = {}, onOpenMenu = {}, items = persistentListOf()),
@ -414,6 +433,7 @@ private class TangemPayDetailsUMProvider : CollectionPreviewParameterProvider<Ta
),
isBalanceHidden = false,
addFundsEnabled = true,
accountDeactivatedNotificationConfig = null,
),
),
)

View file

@ -26,6 +26,7 @@ internal class WalletTangemPayAnalyticsEventSender @Inject constructor(
PaymentAccountStatusValue.Loading,
PaymentAccountStatusValue.NotCreated,
is PaymentAccountStatusValue.UnderReview,
is PaymentAccountStatusValue.Deactivated,
-> return
}

View file

@ -211,6 +211,7 @@ internal class GetMultiWalletWarningsFactory @Inject constructor(
is PaymentAccountStatusValue.Loading,
is PaymentAccountStatusValue.UnderReview,
is PaymentAccountStatusValue.Empty,
is PaymentAccountStatusValue.Deactivated,
-> null
}
notification?.let(::add)

View file

@ -239,6 +239,7 @@ internal class GetWalletNotificationsFactory @Inject constructor(
is PaymentAccountStatusValue.Loading,
is PaymentAccountStatusValue.UnderReview,
is PaymentAccountStatusValue.Empty,
is PaymentAccountStatusValue.Deactivated,
-> null
}
notification?.let(::add)

View file

@ -56,6 +56,32 @@ internal class TangemPayMainBlockConverter(
is PaymentAccountStatusValue.Empty -> TangemPayMainUM.Empty
is PaymentAccountStatusValue.NotCreated -> TangemPayMainUM.Empty
is PaymentAccountStatusValue.Loading -> TangemPayMainUM.Loading
is PaymentAccountStatusValue.Deactivated -> TangemPayMainUM.Content(
subtitle = TextReference.Res(R.string.tangempay_status_deactivated),
isBalanceFlickering = statusValue.source == StatusSource.CACHE,
balance = getBalanceText(
currencyCode = statusValue.fiatBalance.currency,
balance = statusValue.fiatBalance.availableBalance,
),
balanceSubtitle = stringReference("USDC"), // TODO hardcode for now
shouldShowOnlyCacheWarning = statusValue.source == StatusSource.ONLY_CACHE,
onClick = {
// Dummy config for deactivated account just to open details screen
tangemPayClickIntents.openDetails(
userWalletId = value.account.userWalletId,
config = TangemPayDetailsConfig(
customerId = "",
cardId = "",
isPinSet = false,
cardFrozenState = TangemPayCardFrozenState.Unfrozen,
cardNumberEnd = "",
chainId = POLYGON_CHAIN_ID,
displayName = null,
isTangemPayDeactivated = true,
),
)
},
)
is PaymentAccountStatusValue.Loaded -> {
val card = statusValue.cards.firstOrNull() ?: return TangemPayMainUM.TemporaryUnavailable
TangemPayMainUM.Content(
@ -82,6 +108,7 @@ internal class TangemPayMainBlockConverter(
cardNumberEnd = card.lastDigits,
chainId = POLYGON_CHAIN_ID,
displayName = card.displayName,
isTangemPayDeactivated = false,
),
)
},

View file

@ -5,10 +5,10 @@ import com.tangem.domain.walletconnect.WcPairService
import com.tangem.domain.walletconnect.model.WcPairRequest
import com.tangem.domain.wallets.usecase.GetSelectedWalletSyncUseCase
import com.tangem.features.walletconnect.components.deeplink.WalletConnectDeepLinkHandler
import com.tangem.utils.logging.TangemLogger
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import com.tangem.utils.logging.TangemLogger
import java.net.URLDecoder
internal class DefaultWalletConnectDeepLinkHandler @AssistedInject constructor(
@ -18,8 +18,12 @@ internal class DefaultWalletConnectDeepLinkHandler @AssistedInject constructor(
) : WalletConnectDeepLinkHandler {
init {
val wcUri = uri.toString()
if (uri.toString().isNotBlank()) {
val wcUri =
// deeplink type "tangem://wc/?uri=wc:..."
runCatching { uri.getQueryParameter("uri") }.getOrNull()
// direct deeplink "wc:..."
?: uri.toString()
if (wcUri.isNotBlank()) {
try {
// It is okay here, we are navigating from outside, and there is no other way to getting UserWallet
getSelectedWalletSyncUseCase().fold(

View file

@ -21,6 +21,7 @@ import com.tangem.domain.common.wallets.error.SaveWalletError
import com.tangem.domain.common.wallets.error.UnlockWalletError
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.models.wallet.isImported
import com.tangem.domain.models.wallet.isLocked
import com.tangem.domain.settings.CanUseBiometryUseCase
import com.tangem.domain.settings.HotWalletRestrictionManager
@ -198,7 +199,6 @@ internal class WelcomeModel @Inject constructor(
analyticsSource = AnalyticsParam.ScreensSources.SignIn,
shouldCheckIsAlreadyActivated = true,
onWalletNotCreated = {},
disclaimerWillShow = { router.pop() },
onSuccess = { scanResponse ->
val userWallet =
coldUserWalletBuilderFactory.create(scanResponse = scanResponse).build() ?: return@scan
@ -320,10 +320,16 @@ internal class WelcomeModel @Inject constructor(
private suspend fun trackSignInEvent(userWallet: UserWallet, type: Basic.SignedIn.SignInType) {
val walletsCount = userWalletsListRepository.userWalletsSync().size
trackingContextProxy.addContext(userWallet)
val isBackedUp = when (userWallet) {
is UserWallet.Cold -> userWallet.scanResponse.card.backupStatus?.isActive == true
is UserWallet.Hot -> userWallet.backedUp
}
analyticsEventHandler.send(
event = Basic.SignedIn(
signInType = type,
walletsCount = walletsCount,
isImported = userWallet.isImported(),
hasBackup = isBackedUp,
),
)
}