From 0c7586106e5f6c996b375fad5759b1cabc68cb8d Mon Sep 17 00:00:00 2001 From: Tangem Date: Thu, 25 Jun 2026 03:20:02 -0700 Subject: [PATCH] Updated on 2026-08-14 --- .../local/preferences/PreferencesKeys.kt | 2 +- data/visa/build.gradle.kts | 1 + .../pay/DefaultTangemPayEligibilityManager.kt | 13 +- .../di/VirtualAccountDataModule.kt | 26 +++ .../models/pay/TangemPayEligibilityType.kt | 36 +++- .../tangem/domain/models/wallet/UserWallet.kt | 9 +- domain/virtual-account/build.gradle.kts | 20 ++ .../virtual-account/models/build.gradle.kts | 1 + .../model/VirtualAccountEligibility.kt | 12 ++ .../model/VirtualAccountEntryPoint.kt | 7 + .../GetVirtualAccountEligibilityUseCase.kt | 69 +++++++ ...GetVirtualAccountSuitableWalletsUseCase.kt | 17 ++ ...GetVirtualAccountEligibilityUseCaseTest.kt | 174 ++++++++++++++++++ ...irtualAccountSuitableWalletsUseCaseTest.kt | 52 ++++++ features/details/impl/build.gradle.kts | 1 + .../features/details/model/DetailsModel.kt | 32 ++++ .../features/details/utils/ItemsBuilder.kt | 34 ++++ 17 files changed, 491 insertions(+), 15 deletions(-) create mode 100644 domain/virtual-account/models/src/main/kotlin/com/tangem/domain/virtualaccount/model/VirtualAccountEligibility.kt create mode 100644 domain/virtual-account/models/src/main/kotlin/com/tangem/domain/virtualaccount/model/VirtualAccountEntryPoint.kt create mode 100644 domain/virtual-account/src/main/kotlin/com/tangem/domain/virtualaccount/usecase/GetVirtualAccountEligibilityUseCase.kt create mode 100644 domain/virtual-account/src/main/kotlin/com/tangem/domain/virtualaccount/usecase/GetVirtualAccountSuitableWalletsUseCase.kt create mode 100644 domain/virtual-account/src/test/kotlin/com/tangem/domain/virtualaccount/usecase/GetVirtualAccountEligibilityUseCaseTest.kt create mode 100644 domain/virtual-account/src/test/kotlin/com/tangem/domain/virtualaccount/usecase/GetVirtualAccountSuitableWalletsUseCaseTest.kt diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/preferences/PreferencesKeys.kt b/core/datasource/src/main/java/com/tangem/datasource/local/preferences/PreferencesKeys.kt index 32568b5de7..84dc5ebd31 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/local/preferences/PreferencesKeys.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/local/preferences/PreferencesKeys.kt @@ -156,7 +156,7 @@ object PreferencesKeys { val TANGEM_PAY_ACTIVE_WITHDRAW_ORDERS_KEY by lazy { stringPreferencesKey(name = "tangemPayActiveWithdrawOrdersKey") } - val TANGEM_PAY_ELIGIBILITY_KEY by lazy { stringSetPreferencesKey(name = "tangemPayEligibilityList") } + val TANGEM_PAY_ELIGIBILITY_KEY by lazy { stringSetPreferencesKey(name = "tangemPayEligibilityListV2") } fun getShouldShowNotificationKey(key: String) = booleanPreferencesKey("showShowNotificationUM_$key") // endregion diff --git a/data/visa/build.gradle.kts b/data/visa/build.gradle.kts index 5a5ccd9dec..2cc54a86b8 100644 --- a/data/visa/build.gradle.kts +++ b/data/visa/build.gradle.kts @@ -34,6 +34,7 @@ dependencies { /** Project - Domain */ implementation(projects.domain.visa) + implementation(projects.domain.virtualAccount) implementation(projects.domain.card) implementation(projects.domain.wallets) implementation(projects.domain.legacy) diff --git a/data/visa/src/main/kotlin/com/tangem/data/pay/DefaultTangemPayEligibilityManager.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/DefaultTangemPayEligibilityManager.kt index 8ca4cbc613..6f439a26bd 100644 --- a/data/visa/src/main/kotlin/com/tangem/data/pay/DefaultTangemPayEligibilityManager.kt +++ b/data/visa/src/main/kotlin/com/tangem/data/pay/DefaultTangemPayEligibilityManager.kt @@ -1,17 +1,17 @@ package com.tangem.data.pay -import com.tangem.common.card.FirmwareVersion import com.tangem.domain.common.wallets.UserWalletsListRepository import com.tangem.domain.models.pay.TangemPayEligibilityType +import com.tangem.domain.models.pay.isTangemPayType import com.tangem.utils.coroutines.AppCoroutineScope import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.models.wallet.isLocked import com.tangem.domain.models.wallet.isMultiCurrency +import com.tangem.domain.models.wallet.isTangemPayCompatible import com.tangem.domain.pay.TangemPayEligibilityManager import com.tangem.domain.pay.model.TangemPayEntryPoint import com.tangem.domain.pay.repository.OnboardingRepository -import com.tangem.hot.sdk.model.HotWalletId import kotlinx.coroutines.* import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.sync.Mutex @@ -85,7 +85,7 @@ internal class DefaultTangemPayEligibilityManager @Inject constructor( val wallets = userWalletsListRepository.userWallets.value ?: return emptyList() val candidates = wallets.filter { wallet -> - wallet.isMultiCurrency && !wallet.isLocked && wallet.isCompatible() && + wallet.isMultiCurrency && !wallet.isLocked && wallet.isTangemPayCompatible && !onboardingRepository.isTangemPayDeactivated(wallet.walletId) } @@ -98,11 +98,6 @@ internal class DefaultTangemPayEligibilityManager @Inject constructor( return candidates } - private fun UserWallet.isCompatible(): Boolean = when (this) { - is UserWallet.Cold -> scanResponse.card.firmwareVersion >= FirmwareVersion.HDWalletAvailable - is UserWallet.Hot -> hotWalletId.authType != HotWalletId.AuthType.NoPassword - } - private suspend fun List.addPaeraCustomersData(): List { if (isEmpty()) return emptyList() @@ -139,7 +134,7 @@ internal class DefaultTangemPayEligibilityManager @Inject constructor( onboardingRepository.checkCustomerEligibility() } return if (entryPoint == null) { - eligibility.isNotEmpty() + eligibility.any { it.isTangemPayType } } else { eligibility.any { it == entryPoint.toEligibilityType() } } diff --git a/data/visa/src/main/kotlin/com/tangem/data/virtualaccount/di/VirtualAccountDataModule.kt b/data/visa/src/main/kotlin/com/tangem/data/virtualaccount/di/VirtualAccountDataModule.kt index a85c3e8c6d..89d3b807c2 100644 --- a/data/visa/src/main/kotlin/com/tangem/data/virtualaccount/di/VirtualAccountDataModule.kt +++ b/data/visa/src/main/kotlin/com/tangem/data/virtualaccount/di/VirtualAccountDataModule.kt @@ -18,8 +18,13 @@ import com.tangem.datasource.utils.mapWithStringKeyTypes import com.tangem.domain.virtualaccount.flow.VirtualAccountStatusFetcher import com.tangem.domain.virtualaccount.flow.VirtualAccountStatusProducer import com.tangem.domain.virtualaccount.flow.VirtualAccountStatusSupplier +import com.tangem.domain.common.wallets.UserWalletsListRepository +import com.tangem.domain.pay.repository.OnboardingRepository import com.tangem.domain.virtualaccount.repository.VirtualAccountActivationRepository import com.tangem.domain.virtualaccount.usecase.ActivateVirtualAccountUseCase +import com.tangem.domain.virtualaccount.usecase.GetVirtualAccountEligibilityUseCase +import com.tangem.domain.virtualaccount.usecase.GetVirtualAccountSuitableWalletsUseCase +import com.tangem.security.DeviceSecurityInfoProvider import com.tangem.utils.coroutines.AppCoroutineScope import dagger.Binds import dagger.Module @@ -94,5 +99,26 @@ internal interface VirtualAccountDataModule { ): ActivateVirtualAccountUseCase { return ActivateVirtualAccountUseCase(repository = repository) } + + @Provides + @Singleton + fun provideGetVirtualAccountSuitableWalletsUseCase( + userWalletsListRepository: UserWalletsListRepository, + ): GetVirtualAccountSuitableWalletsUseCase { + return GetVirtualAccountSuitableWalletsUseCase(userWalletsListRepository = userWalletsListRepository) + } + + @Provides + fun provideGetVirtualAccountEligibilityUseCase( + getVirtualAccountSuitableWalletsUseCase: GetVirtualAccountSuitableWalletsUseCase, + onboardingRepository: OnboardingRepository, + deviceSecurityInfoProvider: DeviceSecurityInfoProvider, + ): GetVirtualAccountEligibilityUseCase { + return GetVirtualAccountEligibilityUseCase( + getVirtualAccountSuitableWalletsUseCase = getVirtualAccountSuitableWalletsUseCase, + onboardingRepository = onboardingRepository, + deviceSecurityInfoProvider = deviceSecurityInfoProvider, + ) + } } } \ No newline at end of file diff --git a/domain/models/src/main/kotlin/com/tangem/domain/models/pay/TangemPayEligibilityType.kt b/domain/models/src/main/kotlin/com/tangem/domain/models/pay/TangemPayEligibilityType.kt index 2431867555..8c2bf08afa 100644 --- a/domain/models/src/main/kotlin/com/tangem/domain/models/pay/TangemPayEligibilityType.kt +++ b/domain/models/src/main/kotlin/com/tangem/domain/models/pay/TangemPayEligibilityType.kt @@ -4,14 +4,42 @@ enum class TangemPayEligibilityType { BANNER, DETAILS, + DEEPLINK, + + BANNER_VIRTUAL_ACCOUNT, + DETAILS_VIRTUAL_ACCOUNT, + DEEPLINK_VIRTUAL_ACCOUNT, + UNKNOWN, ; companion object { - fun fromString(value: String): TangemPayEligibilityType = when (value.lowercase()) { - "banner" -> BANNER - "details" -> DETAILS + fun fromString(value: String): TangemPayEligibilityType = when (value.uppercase()) { + "BANNER" -> BANNER + "DETAILS" -> DETAILS + "DEEPLINK" -> DEEPLINK + "BANNER_VIRTUAL_ACCOUNT" -> BANNER_VIRTUAL_ACCOUNT + "DETAILS_VIRTUAL_ACCOUNT" -> DETAILS_VIRTUAL_ACCOUNT + "DEEPLINK_VIRTUAL_ACCOUNT" -> DEEPLINK_VIRTUAL_ACCOUNT else -> UNKNOWN } } -} \ No newline at end of file +} + +val TangemPayEligibilityType.isVirtualAccountType: Boolean + get() = this in VIRTUAL_ACCOUNT_TYPES + +val TangemPayEligibilityType.isTangemPayType: Boolean + get() = this in TANGEM_PAY_TYPES + +private val VIRTUAL_ACCOUNT_TYPES = setOf( + TangemPayEligibilityType.BANNER_VIRTUAL_ACCOUNT, + TangemPayEligibilityType.DETAILS_VIRTUAL_ACCOUNT, + TangemPayEligibilityType.DEEPLINK_VIRTUAL_ACCOUNT, +) + +private val TANGEM_PAY_TYPES = setOf( + TangemPayEligibilityType.BANNER, + TangemPayEligibilityType.DETAILS, + TangemPayEligibilityType.DEEPLINK, +) \ No newline at end of file diff --git a/domain/models/src/main/kotlin/com/tangem/domain/models/wallet/UserWallet.kt b/domain/models/src/main/kotlin/com/tangem/domain/models/wallet/UserWallet.kt index acc0333fe9..2fea13cb92 100644 --- a/domain/models/src/main/kotlin/com/tangem/domain/models/wallet/UserWallet.kt +++ b/domain/models/src/main/kotlin/com/tangem/domain/models/wallet/UserWallet.kt @@ -1,5 +1,6 @@ package com.tangem.domain.models.wallet +import com.tangem.common.card.FirmwareVersion import com.tangem.domain.models.MobileWallet import com.tangem.domain.models.scan.CardDTO import com.tangem.domain.models.scan.ScanResponse @@ -118,4 +119,10 @@ val UserWallet.isLocked } inline val UserWallet.isHotWallet get() = this is UserWallet.Hot -inline val UserWallet.isColdWallet get() = this is UserWallet.Cold \ No newline at end of file +inline val UserWallet.isColdWallet get() = this is UserWallet.Cold + +val UserWallet.isTangemPayCompatible: Boolean + get() = when (this) { + is UserWallet.Cold -> scanResponse.card.firmwareVersion >= FirmwareVersion.HDWalletAvailable + is UserWallet.Hot -> hotWalletId.authType != HotWalletId.AuthType.NoPassword + } \ No newline at end of file diff --git a/domain/virtual-account/build.gradle.kts b/domain/virtual-account/build.gradle.kts index ff053920b6..618b957012 100644 --- a/domain/virtual-account/build.gradle.kts +++ b/domain/virtual-account/build.gradle.kts @@ -10,4 +10,24 @@ android { } dependencies { + /** Project - Domain */ + api(projects.domain.models) + api(projects.domain.virtualAccount.models) + implementation(projects.domain.common) + implementation(projects.domain.visa) + + /** Project - Core */ + implementation(projects.core.security) + + /** Coroutines */ + implementation(deps.kotlin.coroutines) + + /** Tests */ + testImplementation(deps.test.junit5) + testImplementation(deps.test.mockk) + testImplementation(deps.test.coroutine) + testImplementation(deps.test.truth) + testImplementation(projects.test.core) + testImplementation(projects.common.test) + testImplementation(projects.domain.card) } \ No newline at end of file diff --git a/domain/virtual-account/models/build.gradle.kts b/domain/virtual-account/models/build.gradle.kts index d587d7c152..0604c48d68 100644 --- a/domain/virtual-account/models/build.gradle.kts +++ b/domain/virtual-account/models/build.gradle.kts @@ -10,4 +10,5 @@ android { } dependencies { + api(projects.domain.models) } \ No newline at end of file diff --git a/domain/virtual-account/models/src/main/kotlin/com/tangem/domain/virtualaccount/model/VirtualAccountEligibility.kt b/domain/virtual-account/models/src/main/kotlin/com/tangem/domain/virtualaccount/model/VirtualAccountEligibility.kt new file mode 100644 index 0000000000..23d9bf4583 --- /dev/null +++ b/domain/virtual-account/models/src/main/kotlin/com/tangem/domain/virtualaccount/model/VirtualAccountEligibility.kt @@ -0,0 +1,12 @@ +package com.tangem.domain.virtualaccount.model + +import com.tangem.domain.models.wallet.UserWallet + +sealed interface VirtualAccountEligibility { + + data class Available( + val wallets: List, + ) : VirtualAccountEligibility + + data object NotAvailable : VirtualAccountEligibility +} \ No newline at end of file diff --git a/domain/virtual-account/models/src/main/kotlin/com/tangem/domain/virtualaccount/model/VirtualAccountEntryPoint.kt b/domain/virtual-account/models/src/main/kotlin/com/tangem/domain/virtualaccount/model/VirtualAccountEntryPoint.kt new file mode 100644 index 0000000000..fdc50dcb4a --- /dev/null +++ b/domain/virtual-account/models/src/main/kotlin/com/tangem/domain/virtualaccount/model/VirtualAccountEntryPoint.kt @@ -0,0 +1,7 @@ +package com.tangem.domain.virtualaccount.model + +enum class VirtualAccountEntryPoint { + BANNER, + DETAILS, + DEEPLINK, +} \ No newline at end of file diff --git a/domain/virtual-account/src/main/kotlin/com/tangem/domain/virtualaccount/usecase/GetVirtualAccountEligibilityUseCase.kt b/domain/virtual-account/src/main/kotlin/com/tangem/domain/virtualaccount/usecase/GetVirtualAccountEligibilityUseCase.kt new file mode 100644 index 0000000000..1d4a854342 --- /dev/null +++ b/domain/virtual-account/src/main/kotlin/com/tangem/domain/virtualaccount/usecase/GetVirtualAccountEligibilityUseCase.kt @@ -0,0 +1,69 @@ +package com.tangem.domain.virtualaccount.usecase + +import com.tangem.domain.models.pay.TangemPayEligibilityType +import com.tangem.domain.models.pay.isVirtualAccountType +import com.tangem.domain.pay.repository.OnboardingRepository +import com.tangem.domain.virtualaccount.model.VirtualAccountEligibility +import com.tangem.domain.virtualaccount.model.VirtualAccountEntryPoint +import com.tangem.security.DeviceSecurityInfoProvider +import com.tangem.security.isSecurityExposed +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope + +class GetVirtualAccountEligibilityUseCase( + private val getVirtualAccountSuitableWalletsUseCase: GetVirtualAccountSuitableWalletsUseCase, + private val onboardingRepository: OnboardingRepository, + private val deviceSecurityInfoProvider: DeviceSecurityInfoProvider, +) { + suspend operator fun invoke(entryPoint: VirtualAccountEntryPoint?): VirtualAccountEligibility { + if (deviceSecurityInfoProvider.isSecurityExposed()) { + return VirtualAccountEligibility.NotAvailable + } + + val suitableWallets = getVirtualAccountSuitableWalletsUseCase() + if (suitableWallets.isEmpty()) { + return VirtualAccountEligibility.NotAvailable + } + + val isEligible = checkEligibility(entryPoint) + if (isEligible) { + return VirtualAccountEligibility.Available(suitableWallets) + } + + val eligibleWallets = coroutineScope { + suitableWallets + .map { wallet -> + async { + val isExistingCustomer = onboardingRepository.hasTangemPayInWallet(wallet.walletId).getOrNull() + wallet.takeIf { isExistingCustomer == true } + } + } + .awaitAll() + .filterNotNull() + } + + return if (eligibleWallets.isEmpty()) { + VirtualAccountEligibility.NotAvailable + } else { + VirtualAccountEligibility.Available(eligibleWallets) + } + } + + private suspend fun checkEligibility(entryPoint: VirtualAccountEntryPoint?): Boolean { + val eligibility = onboardingRepository.getCustomerEligibility().ifEmpty { + onboardingRepository.checkCustomerEligibility() + } + return if (entryPoint == null) { + eligibility.any { it.isVirtualAccountType } + } else { + eligibility.contains(entryPoint.toEligibilityType()) + } + } + + private fun VirtualAccountEntryPoint.toEligibilityType(): TangemPayEligibilityType = when (this) { + VirtualAccountEntryPoint.BANNER -> TangemPayEligibilityType.BANNER_VIRTUAL_ACCOUNT + VirtualAccountEntryPoint.DETAILS -> TangemPayEligibilityType.DETAILS_VIRTUAL_ACCOUNT + VirtualAccountEntryPoint.DEEPLINK -> TangemPayEligibilityType.DEEPLINK_VIRTUAL_ACCOUNT + } +} \ No newline at end of file diff --git a/domain/virtual-account/src/main/kotlin/com/tangem/domain/virtualaccount/usecase/GetVirtualAccountSuitableWalletsUseCase.kt b/domain/virtual-account/src/main/kotlin/com/tangem/domain/virtualaccount/usecase/GetVirtualAccountSuitableWalletsUseCase.kt new file mode 100644 index 0000000000..8a14d69c48 --- /dev/null +++ b/domain/virtual-account/src/main/kotlin/com/tangem/domain/virtualaccount/usecase/GetVirtualAccountSuitableWalletsUseCase.kt @@ -0,0 +1,17 @@ +package com.tangem.domain.virtualaccount.usecase + +import com.tangem.domain.common.wallets.UserWalletsListRepository +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.models.wallet.isLocked +import com.tangem.domain.models.wallet.isMultiCurrency +import com.tangem.domain.models.wallet.isTangemPayCompatible + +class GetVirtualAccountSuitableWalletsUseCase( + private val userWalletsListRepository: UserWalletsListRepository, +) { + operator fun invoke(): List { + return userWalletsListRepository.userWallets.value + .orEmpty() + .filter { it.isMultiCurrency && !it.isLocked && it.isTangemPayCompatible } + } +} \ No newline at end of file diff --git a/domain/virtual-account/src/test/kotlin/com/tangem/domain/virtualaccount/usecase/GetVirtualAccountEligibilityUseCaseTest.kt b/domain/virtual-account/src/test/kotlin/com/tangem/domain/virtualaccount/usecase/GetVirtualAccountEligibilityUseCaseTest.kt new file mode 100644 index 0000000000..2e83b585b0 --- /dev/null +++ b/domain/virtual-account/src/test/kotlin/com/tangem/domain/virtualaccount/usecase/GetVirtualAccountEligibilityUseCaseTest.kt @@ -0,0 +1,174 @@ +package com.tangem.domain.virtualaccount.usecase + +import arrow.core.left +import arrow.core.right +import com.google.common.truth.Truth.assertThat +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.repository.OnboardingRepository +import com.tangem.domain.virtualaccount.model.VirtualAccountEligibility +import com.tangem.domain.virtualaccount.model.VirtualAccountEntryPoint +import com.tangem.domain.visa.error.VisaApiError +import com.tangem.security.DeviceSecurityInfoProvider +import io.mockk.clearMocks +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +internal class GetVirtualAccountEligibilityUseCaseTest { + + private val getVirtualAccountSuitableWalletsUseCase: GetVirtualAccountSuitableWalletsUseCase = mockk() + private val onboardingRepository: OnboardingRepository = mockk() + private val deviceSecurityInfoProvider: DeviceSecurityInfoProvider = mockk() + + private val useCase = GetVirtualAccountEligibilityUseCase( + getVirtualAccountSuitableWalletsUseCase = getVirtualAccountSuitableWalletsUseCase, + onboardingRepository = onboardingRepository, + deviceSecurityInfoProvider = deviceSecurityInfoProvider, + ) + + @BeforeEach + fun setup() { + clearMocks(getVirtualAccountSuitableWalletsUseCase, onboardingRepository, deviceSecurityInfoProvider) + every { deviceSecurityInfoProvider.isRooted } returns false + every { deviceSecurityInfoProvider.isBootloaderUnlocked } returns false + every { deviceSecurityInfoProvider.isXposed } returns false + } + + @Test + fun `GIVEN device is rooted WHEN invoke THEN returns NotAvailable`() = runTest { + // GIVEN + every { deviceSecurityInfoProvider.isRooted } returns true + + // WHEN + val result = useCase(VirtualAccountEntryPoint.BANNER) + + // THEN + assertThat(result).isEqualTo(VirtualAccountEligibility.NotAvailable) + } + + @Test + fun `GIVEN no suitable wallets WHEN invoke THEN returns NotAvailable`() = runTest { + // GIVEN + every { getVirtualAccountSuitableWalletsUseCase() } returns emptyList() + + // WHEN + val result = useCase(VirtualAccountEntryPoint.BANNER) + + // THEN + assertThat(result).isEqualTo(VirtualAccountEligibility.NotAvailable) + } + + @Test + fun `GIVEN entry point eligibility passes WHEN invoke THEN returns Available with all suitable wallets`() = runTest { + // GIVEN + val wallets = listOf(mockWallet(), mockWallet()) + every { getVirtualAccountSuitableWalletsUseCase() } returns wallets + coEvery { + onboardingRepository.getCustomerEligibility() + } returns listOf(TangemPayEligibilityType.BANNER_VIRTUAL_ACCOUNT) + + // WHEN + val result = useCase(VirtualAccountEntryPoint.BANNER) + + // THEN + assertThat(result).isEqualTo(VirtualAccountEligibility.Available(wallets)) + } + + @Test + fun `GIVEN null entry point AND any VA eligibility present WHEN invoke THEN returns Available`() = runTest { + // GIVEN + val wallets = listOf(mockWallet()) + every { getVirtualAccountSuitableWalletsUseCase() } returns wallets + coEvery { + onboardingRepository.getCustomerEligibility() + } returns listOf(TangemPayEligibilityType.DEEPLINK_VIRTUAL_ACCOUNT) + + // WHEN + val result = useCase(entryPoint = null) + + // THEN + assertThat(result).isEqualTo(VirtualAccountEligibility.Available(wallets)) + } + + @Test + fun `GIVEN cached eligibility empty WHEN invoke THEN falls back to fetched eligibility`() = runTest { + // GIVEN + val wallets = listOf(mockWallet()) + every { getVirtualAccountSuitableWalletsUseCase() } returns wallets + coEvery { onboardingRepository.getCustomerEligibility() } returns emptyList() + coEvery { + onboardingRepository.checkCustomerEligibility() + } returns listOf(TangemPayEligibilityType.BANNER_VIRTUAL_ACCOUNT) + + // WHEN + val result = useCase(VirtualAccountEntryPoint.BANNER) + + // THEN + assertThat(result).isEqualTo(VirtualAccountEligibility.Available(wallets)) + } + + @Test + fun `GIVEN eligibility fails AND wallet is existing customer WHEN invoke THEN returns Available with wallet`() = + runTest { + // GIVEN + val wallet = mockWallet() + every { getVirtualAccountSuitableWalletsUseCase() } returns listOf(wallet) + coEvery { onboardingRepository.getCustomerEligibility() } returns listOf(TangemPayEligibilityType.BANNER) + coEvery { onboardingRepository.hasTangemPayInWallet(wallet.walletId) } returns true.right() + + // WHEN + val result = useCase(VirtualAccountEntryPoint.BANNER) + + // THEN + assertThat(result).isEqualTo(VirtualAccountEligibility.Available(listOf(wallet))) + } + + @Test + fun `GIVEN eligibility fails AND wallet is not a customer WHEN invoke THEN returns NotAvailable`() = runTest { + // GIVEN + val wallet = mockWallet() + every { getVirtualAccountSuitableWalletsUseCase() } returns listOf(wallet) + coEvery { onboardingRepository.getCustomerEligibility() } returns listOf(TangemPayEligibilityType.BANNER) + coEvery { + onboardingRepository.hasTangemPayInWallet(wallet.walletId) + } returns VisaApiError.NotPaeraCustomer.left() + + // WHEN + val result = useCase(VirtualAccountEntryPoint.BANNER) + + // THEN + assertThat(result).isEqualTo(VirtualAccountEligibility.NotAvailable) + } + + @Test + fun `GIVEN eligibility fails AND only some wallets are customers WHEN invoke THEN returns Available with customers`() = + runTest { + // GIVEN + val customerWallet = mockWallet() + val nonCustomerWallet = mockWallet() + every { + getVirtualAccountSuitableWalletsUseCase() + } returns listOf(customerWallet, nonCustomerWallet) + coEvery { onboardingRepository.getCustomerEligibility() } returns listOf(TangemPayEligibilityType.BANNER) + coEvery { onboardingRepository.hasTangemPayInWallet(customerWallet.walletId) } returns true.right() + coEvery { onboardingRepository.hasTangemPayInWallet(nonCustomerWallet.walletId) } returns false.right() + + // WHEN + val result = useCase(VirtualAccountEntryPoint.BANNER) + + // THEN + assertThat(result).isEqualTo(VirtualAccountEligibility.Available(listOf(customerWallet))) + } + + private fun mockWallet(): UserWallet { + val id = mockk() + return mockk { every { walletId } returns id } + } +} \ No newline at end of file diff --git a/domain/virtual-account/src/test/kotlin/com/tangem/domain/virtualaccount/usecase/GetVirtualAccountSuitableWalletsUseCaseTest.kt b/domain/virtual-account/src/test/kotlin/com/tangem/domain/virtualaccount/usecase/GetVirtualAccountSuitableWalletsUseCaseTest.kt new file mode 100644 index 0000000000..40883c03ce --- /dev/null +++ b/domain/virtual-account/src/test/kotlin/com/tangem/domain/virtualaccount/usecase/GetVirtualAccountSuitableWalletsUseCaseTest.kt @@ -0,0 +1,52 @@ +package com.tangem.domain.virtualaccount.usecase + +import com.google.common.truth.Truth.assertThat +import com.tangem.common.test.domain.card.MockScanResponseFactory +import com.tangem.common.test.domain.wallet.MockUserWalletFactory +import com.tangem.domain.card.configs.GenericCardConfig +import com.tangem.domain.card.configs.Wallet2CardConfig +import com.tangem.domain.common.wallets.UserWalletsListRepository +import com.tangem.domain.models.wallet.UserWallet +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.flow.MutableStateFlow +import org.junit.jupiter.api.Test + +internal class GetVirtualAccountSuitableWalletsUseCaseTest { + + private val userWalletsListRepository: UserWalletsListRepository = mockk() + + private val useCase = GetVirtualAccountSuitableWalletsUseCase(userWalletsListRepository = userWalletsListRepository) + + @Test + fun `GIVEN compatible, single-currency and outdated wallets WHEN invoke THEN returns only the compatible one`() { + // GIVEN + val compatible = MockUserWalletFactory.create( + MockScanResponseFactory.create(cardConfig = Wallet2CardConfig, derivedKeys = emptyMap()), + ) + val singleCurrency = MockUserWalletFactory.createSingleWalletWithToken() + val outdatedFirmware = MockUserWalletFactory.create( + MockScanResponseFactory.create(cardConfig = GenericCardConfig(maxWalletCount = 2), derivedKeys = emptyMap()), + ) + every { userWalletsListRepository.userWallets } returns + MutableStateFlow(listOf(compatible, singleCurrency, outdatedFirmware)) + + // WHEN + val result = useCase() + + // THEN + assertThat(result).containsExactly(compatible) + } + + @Test + fun `GIVEN no wallets WHEN invoke THEN returns empty list`() { + // GIVEN + every { userWalletsListRepository.userWallets } returns MutableStateFlow(null) + + // WHEN + val result = useCase() + + // THEN + assertThat(result).isEmpty() + } +} \ No newline at end of file diff --git a/features/details/impl/build.gradle.kts b/features/details/impl/build.gradle.kts index 3b6e587c78..51324ed3de 100644 --- a/features/details/impl/build.gradle.kts +++ b/features/details/impl/build.gradle.kts @@ -50,6 +50,7 @@ dependencies { implementation(projects.domain.legacy) implementation(projects.domain.settings) implementation(projects.domain.visa) + implementation(projects.domain.virtualAccount) /* SDK */ // TODO: For TangemError model, should be removed after card domain scanning refactoring diff --git a/features/details/impl/src/main/kotlin/com/tangem/features/details/model/DetailsModel.kt b/features/details/impl/src/main/kotlin/com/tangem/features/details/model/DetailsModel.kt index 6e8d03041f..9562662f33 100644 --- a/features/details/impl/src/main/kotlin/com/tangem/features/details/model/DetailsModel.kt +++ b/features/details/impl/src/main/kotlin/com/tangem/features/details/model/DetailsModel.kt @@ -22,6 +22,9 @@ import com.tangem.domain.pay.TangemPayEligibilityManager import com.tangem.domain.pay.model.TangemPayEntryPoint import com.tangem.domain.tangempay.GetTangemPayCustomerIdUseCase import com.tangem.domain.tangempay.TangemPayAnalyticsEvents +import com.tangem.domain.virtualaccount.model.VirtualAccountEligibility +import com.tangem.domain.virtualaccount.model.VirtualAccountEntryPoint +import com.tangem.domain.virtualaccount.usecase.GetVirtualAccountEligibilityUseCase import com.tangem.domain.walletconnect.CheckIsWalletConnectAvailableUseCase import com.tangem.domain.wallets.usecase.GenerateBuyTangemCardLinkUseCase import com.tangem.domain.wallets.analytics.Settings @@ -69,6 +72,7 @@ internal class DetailsModel @Inject constructor( private val generateBuyTangemCardLinkUseCase: GenerateBuyTangemCardLinkUseCase, private val analyticsEventHandler: AnalyticsEventHandler, private val tangemPayEligibilityManager: TangemPayEligibilityManager, + private val getVirtualAccountEligibilityUseCase: GetVirtualAccountEligibilityUseCase, ) : Model() { private val params: DetailsComponent.Params = paramsContainer.require() @@ -101,6 +105,7 @@ internal class DetailsModel @Inject constructor( ) addTangemPayItemIfEligible() + addVirtualAccountItemIfEligible() state = MutableStateFlow( value = DetailsUM( @@ -328,5 +333,32 @@ internal class DetailsModel @Inject constructor( } } + private fun addVirtualAccountItemIfEligible() { + modelScope.launch { + val eligibility = getVirtualAccountEligibilityUseCase(VirtualAccountEntryPoint.DETAILS) + if (eligibility is VirtualAccountEligibility.Available) { + items.update { items -> + itemsBuilder.addVirtualAccountItem( + items = items, + onClick = ::onVirtualAccountItemClicked, + ) + } + } + } + } + + private fun onVirtualAccountItemClicked() { + modelScope.launch { + when (val eligibility = getVirtualAccountEligibilityUseCase(VirtualAccountEntryPoint.DETAILS)) { + is VirtualAccountEligibility.Available -> router.push( + AppRoute.VirtualAccountOnboarding( + AppRoute.VirtualAccountOnboarding.Mode.FromDetailsScreen(eligibility.wallets.first().walletId), + ), + ) + VirtualAccountEligibility.NotAvailable -> items.update { itemsBuilder.removeVirtualAccountItem(it) } + } + } + } + private fun getAppVersion(): String = "${appInfoProvider.appVersion} (${appInfoProvider.appVersionCode})" } \ No newline at end of file diff --git a/features/details/impl/src/main/kotlin/com/tangem/features/details/utils/ItemsBuilder.kt b/features/details/impl/src/main/kotlin/com/tangem/features/details/utils/ItemsBuilder.kt index 856b5f8dab..6aca30759b 100644 --- a/features/details/impl/src/main/kotlin/com/tangem/features/details/utils/ItemsBuilder.kt +++ b/features/details/impl/src/main/kotlin/com/tangem/features/details/utils/ItemsBuilder.kt @@ -16,6 +16,7 @@ import kotlinx.collections.immutable.toPersistentList import javax.inject.Inject private const val TANGEM_PAY_ITEM_ID = "get_tangem_pay" +private const val VIRTUAL_ACCOUNT_ITEM_ID = "get_virtual_account" @ModelScoped internal class ItemsBuilder @Inject constructor( @@ -75,6 +76,30 @@ internal class ItemsBuilder @Inject constructor( }.toImmutableList() } + fun addVirtualAccountItem(items: ImmutableList, onClick: () -> Unit): ImmutableList { + return items.map { block -> + if (block.id == "shop" && block is DetailsItemUM.Basic) { + val newItems = block + .items + .toMutableList() + .apply { add(getVirtualAccountItem(onClick = onClick)) } + block.copy(items = newItems.toImmutableList()) + } else { + block + } + }.toImmutableList() + } + + fun removeVirtualAccountItem(items: ImmutableList): ImmutableList { + return items.map { block -> + if (block is DetailsItemUM.Basic && block.items.any { it.id == VIRTUAL_ACCOUNT_ITEM_ID }) { + block.copy(items = block.items.filter { it.id != VIRTUAL_ACCOUNT_ITEM_ID }.toImmutableList()) + } else { + block + } + }.toImmutableList() + } + private fun buildWalletConnectBlock(isWalletConnectAvailable: Boolean, userWalletId: UserWalletId): DetailsItemUM? { return if (isWalletConnectAvailable) { DetailsItemUM.WalletConnect( @@ -173,4 +198,13 @@ internal class ItemsBuilder @Inject constructor( onClick = onClick, ), ) + + private fun getVirtualAccountItem(onClick: () -> Unit): DetailsItemUM.Basic.Item = DetailsItemUM.Basic.Item( + id = VIRTUAL_ACCOUNT_ITEM_ID, + block = BlockUM( + text = resourceReference(R.string.virtual_account_title), + iconRes = R.drawable.ic_tangem_pay_24, + onClick = onClick, + ), + ) } \ No newline at end of file