diff --git a/app/build.gradle.kts b/app/build.gradle.kts index bc3d6cf51b..ee47c1fe61 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -316,6 +316,12 @@ dependencies { implementation(projects.features.tangempay.main.impl) implementation(projects.features.tangempay.onboarding.api) implementation(projects.features.tangempay.onboarding.impl) + implementation(projects.features.virtualAccounts.onboarding.impl) + implementation(projects.features.virtualAccounts.onboarding.api) + implementation(projects.features.virtualAccounts.main.impl) + implementation(projects.features.virtualAccounts.main.api) + implementation(projects.features.virtualAccounts.details.impl) + implementation(projects.features.virtualAccounts.details.api) implementation(projects.features.tokenRecieve.api) implementation(projects.features.tokenRecieve.impl) implementation(projects.features.yieldSupply.api) diff --git a/app/src/main/java/com/tangem/tap/di/domain/TokensDomainModule.kt b/app/src/main/java/com/tangem/tap/di/domain/TokensDomainModule.kt index f852d91fc1..ddceb2ede1 100644 --- a/app/src/main/java/com/tangem/tap/di/domain/TokensDomainModule.kt +++ b/app/src/main/java/com/tangem/tap/di/domain/TokensDomainModule.kt @@ -1,6 +1,7 @@ package com.tangem.tap.di.domain import com.tangem.core.configtoggle.feature.FeatureTogglesManager +import com.tangem.domain.account.status.usecase.IsCryptoCurrencyCouldHideUseCase import com.tangem.domain.account.supplier.SingleAccountListSupplier import com.tangem.domain.common.tokens.CardCryptoCurrencyFactory import com.tangem.domain.common.wallets.UserWalletsListRepository @@ -10,19 +11,20 @@ import com.tangem.domain.networks.multi.MultiNetworkStatusFetcher import com.tangem.domain.networks.repository.NetworksRepository import com.tangem.domain.networks.single.SingleNetworkStatusFetcher import com.tangem.domain.pay.flow.PaymentAccountStatusFetcher -import com.tangem.domain.stories.StoriesRepository import com.tangem.domain.quotes.multi.MultiQuoteStatusFetcher import com.tangem.domain.staking.StakingIdFactory import com.tangem.domain.staking.multi.MultiStakingBalanceFetcher import com.tangem.domain.staking.repositories.StakingRepository -import com.tangem.domain.account.status.usecase.IsCryptoCurrencyCouldHideUseCase +import com.tangem.domain.stories.StoriesRepository import com.tangem.domain.tokens.* import com.tangem.domain.tokens.repository.CurrenciesRepository import com.tangem.domain.tokens.repository.CurrencyChecksRepository import com.tangem.domain.tokens.repository.TokenReceiveWarningsViewedRepository import com.tangem.domain.tokens.repository.YieldSupplyWarningsViewedRepository import com.tangem.domain.tokens.wallet.WalletBalanceFetcher +import com.tangem.domain.virtualaccount.flow.VirtualAccountStatusFetcher import com.tangem.domain.walletmanager.WalletManagersFacade +import com.tangem.features.virtualaccount.VirtualAccountFeatureToggles import com.tangem.tap.domain.tokens.DefaultTokensFeatureToggles import com.tangem.utils.coroutines.CoroutineDispatcherProvider import dagger.Module @@ -162,6 +164,8 @@ internal object TokensDomainModule { multiQuoteStatusFetcher: MultiQuoteStatusFetcher, multiStakingBalanceFetcher: MultiStakingBalanceFetcher, paymentAccountStatusFetcher: PaymentAccountStatusFetcher, + virtualAccountStatusFetcher: VirtualAccountStatusFetcher, + virtualAccountsFeatureToggles: VirtualAccountFeatureToggles, stakingIdFactory: StakingIdFactory, dispatchers: CoroutineDispatcherProvider, ): WalletBalanceFetcher { @@ -175,6 +179,8 @@ internal object TokensDomainModule { multiQuoteStatusFetcher = multiQuoteStatusFetcher, multiStakingBalanceFetcher = multiStakingBalanceFetcher, paymentAccountStatusFetcher = paymentAccountStatusFetcher, + virtualAccountStatusFetcher = virtualAccountStatusFetcher, + virtualAccountsFeatureToggles = virtualAccountsFeatureToggles, stakingIdFactory = stakingIdFactory, dispatchers = dispatchers, ) diff --git a/core/datasource/src/main/java/com/tangem/datasource/di/MoshiModule.kt b/core/datasource/src/main/java/com/tangem/datasource/di/MoshiModule.kt index 61e2c944ec..67c8f3342a 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/di/MoshiModule.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/di/MoshiModule.kt @@ -10,6 +10,7 @@ import com.tangem.datasource.api.common.adapter.* import com.tangem.datasource.local.config.providers.models.ProviderModel import com.tangem.datasource.local.network.entity.NetworkStatusDM import com.tangem.datasource.local.visa.entity.PaymentAccountStatusValueDM +import com.tangem.datasource.local.visa.entity.VirtualAccountStatusValueDM import com.tangem.datasource.utils.SerializeNullsFactory import com.tangem.domain.models.scan.serialization.* import dagger.Module @@ -56,6 +57,18 @@ class MoshiModule { .withSubtype(PaymentAccountStatusValueDM.DeactivatedAccount::class.java, "deactivated_account") .withSubtype(PaymentAccountStatusValueDM.CardIssueFailed::class.java, "card_issue_failed"), ) + .add( + NamePolymorphicAdapterFactory.of(VirtualAccountStatusValueDM::class.java) + .withSubtype(VirtualAccountStatusValueDM.Empty::class.java, "empty") + .withSubtype(VirtualAccountStatusValueDM.NotCreated::class.java, "not_created") + .withSubtype(VirtualAccountStatusValueDM.UnderReview::class.java, "kyc_status") + .withSubtype(VirtualAccountStatusValueDM.Provisioning::class.java, "provisioning") + .withSubtype( + VirtualAccountStatusValueDM.CountryNotSupported::class.java, + "country_not_supported", + ) + .withSubtype(VirtualAccountStatusValueDM.ActiveAccount::class.java, "active_account"), + ) .add( PolymorphicJsonAdapterFactory.of(NFTCollection.Identifier::class.java, "bc") .withSubtype(NFTCollection.Identifier.EVM::class.java, "evm") diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/visa/entity/VirtualAccountStatusValueDM.kt b/core/datasource/src/main/java/com/tangem/datasource/local/visa/entity/VirtualAccountStatusValueDM.kt new file mode 100644 index 0000000000..ee107eb988 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/local/visa/entity/VirtualAccountStatusValueDM.kt @@ -0,0 +1,71 @@ +@file:Suppress("BooleanPropertyNaming") +package com.tangem.datasource.local.visa.entity + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +import com.tangem.domain.models.kyc.KycStatus +import dev.onenowy.moshipolymorphicadapter.PolymorphicAdapterType +import dev.onenowy.moshipolymorphicadapter.annotations.NameLabel +import java.math.BigDecimal + +/** + * Virtual account status for storage in the local cache. + * + * @see [com.tangem.domain.models.account.AccountStatus.Virtual] + */ +@JsonClass(generateAdapter = true, generator = PolymorphicAdapterType.NAME_POLYMORPHIC_ADAPTER) +sealed interface VirtualAccountStatusValueDM { + + @NameLabel("empty") + data class Empty( + @Json(name = "empty") val marker: Boolean = true, + ) : VirtualAccountStatusValueDM + + @NameLabel("not_created") + data class NotCreated( + @Json(name = "not_created") val marker: Boolean = true, + ) : VirtualAccountStatusValueDM + + @NameLabel("kyc_status") + data class UnderReview( + @Json(name = "kyc_status") val kycStatus: KycStatus, + @Json(name = "customer_id") val customerId: String, + ) : VirtualAccountStatusValueDM + + @NameLabel("provisioning") + data class Provisioning( + @Json(name = "provisioning") val marker: Boolean = true, + ) : VirtualAccountStatusValueDM + + @NameLabel("country_not_supported") + data class CountryNotSupported( + @Json(name = "country_not_supported") val marker: Boolean = true, + ) : VirtualAccountStatusValueDM + + @NameLabel("active_account") + data class ActiveAccount( + @Json(name = "active_account") val marker: Boolean = true, + @Json(name = "customer_id") val customerId: String, + @Json(name = "currency_code") val currencyCode: String, + @Json(name = "deposit_address") val depositAddress: String?, + @Json(name = "fiat_balance") val fiatBalance: FiatBalanceDM, + @Json(name = "crypto_balance") val cryptoBalance: CryptoBalanceDM, + @Json(name = "fiat_rate") val fiatRate: BigDecimal?, + @Json(name = "available_for_withdrawal") val availableForWithdrawal: BigDecimal, + ) : VirtualAccountStatusValueDM + + @JsonClass(generateAdapter = true) + data class FiatBalanceDM( + @Json(name = "available_balance") val availableBalance: BigDecimal, + @Json(name = "currency") val currency: String, + ) + + @JsonClass(generateAdapter = true) + data class CryptoBalanceDM( + @Json(name = "id") val id: String, + @Json(name = "chain_id") val chainId: Long, + @Json(name = "deposit_address") val depositAddress: String, + @Json(name = "token_contract_address") val tokenContractAddress: String, + @Json(name = "balance") val balance: BigDecimal, + ) +} \ No newline at end of file diff --git a/core/datasource/src/test/kotlin/com/tangem/datasource/local/visa/entity/VirtualAccountStatusValueDMSerializationTest.kt b/core/datasource/src/test/kotlin/com/tangem/datasource/local/visa/entity/VirtualAccountStatusValueDMSerializationTest.kt new file mode 100644 index 0000000000..b621f4e258 --- /dev/null +++ b/core/datasource/src/test/kotlin/com/tangem/datasource/local/visa/entity/VirtualAccountStatusValueDMSerializationTest.kt @@ -0,0 +1,74 @@ +package com.tangem.datasource.local.visa.entity + +import com.google.common.truth.Truth +import com.squareup.moshi.adapter +import com.tangem.datasource.di.MoshiModule +import org.junit.jupiter.api.Test +import java.math.BigDecimal + +/** + * Verifies that the network Moshi (see [MoshiModule.provideNetworkMoshi]) resolves and round-trips the + * polymorphic [VirtualAccountStatusValueDM] adapter. + * + * Guards against the missing `NamePolymorphicAdapterFactory` registration that caused a runtime + * `ClassNotFoundException: ...VirtualAccountStatusValueDMJsonAdapter` (the adapter is registered manually, + * not generated). + */ +class VirtualAccountStatusValueDMSerializationTest { + + @OptIn(ExperimentalStdlibApi::class) + private val adapter = MoshiModule().provideNetworkMoshi().adapter() + + @Test + fun `round-trip NotCreated`() { + val model: VirtualAccountStatusValueDM = VirtualAccountStatusValueDM.NotCreated() + + val restored = adapter.fromJson(adapter.toJson(model)) + + Truth.assertThat(restored).isInstanceOf(VirtualAccountStatusValueDM.NotCreated::class.java) + } + + @Test + fun `round-trip Provisioning`() { + val model: VirtualAccountStatusValueDM = VirtualAccountStatusValueDM.Provisioning() + + val restored = adapter.fromJson(adapter.toJson(model)) + + Truth.assertThat(restored).isInstanceOf(VirtualAccountStatusValueDM.Provisioning::class.java) + } + + @Test + fun `round-trip CountryNotSupported`() { + val model: VirtualAccountStatusValueDM = VirtualAccountStatusValueDM.CountryNotSupported() + + val restored = adapter.fromJson(adapter.toJson(model)) + + Truth.assertThat(restored).isInstanceOf(VirtualAccountStatusValueDM.CountryNotSupported::class.java) + } + + @Test + fun `round-trip ActiveAccount`() { + val model: VirtualAccountStatusValueDM = VirtualAccountStatusValueDM.ActiveAccount( + customerId = "cust-1", + currencyCode = "USD", + depositAddress = "0xabc", + fiatBalance = VirtualAccountStatusValueDM.FiatBalanceDM( + availableBalance = BigDecimal("101.56"), + currency = "USD", + ), + cryptoBalance = VirtualAccountStatusValueDM.CryptoBalanceDM( + id = "usd-coin", + chainId = 137L, + depositAddress = "0xabc", + tokenContractAddress = "0xdef", + balance = BigDecimal("101.56"), + ), + fiatRate = BigDecimal("0.95"), + availableForWithdrawal = BigDecimal("100.00"), + ) + + val restored = adapter.fromJson(adapter.toJson(model)) + + Truth.assertThat(restored).isEqualTo(model) + } +} \ No newline at end of file diff --git a/data/account/build.gradle.kts b/data/account/build.gradle.kts index e059a1b26c..dd1d1bd8de 100644 --- a/data/account/build.gradle.kts +++ b/data/account/build.gradle.kts @@ -10,6 +10,8 @@ android { } dependencies { + implementation(projects.features.virtualAccounts.details.api) // VIRTUAL_ACCOUNTS_ENABLED + // region Project - Common implementation(projects.common.ui) // It's needed for getting AccountName.DefaultMain value // endregion diff --git a/data/account/src/main/kotlin/com/tangem/data/account/converter/AccountConvertersExt.kt b/data/account/src/main/kotlin/com/tangem/data/account/converter/AccountConvertersExt.kt index 312e189d67..2bb56215bb 100644 --- a/data/account/src/main/kotlin/com/tangem/data/account/converter/AccountConvertersExt.kt +++ b/data/account/src/main/kotlin/com/tangem/data/account/converter/AccountConvertersExt.kt @@ -11,6 +11,7 @@ import com.tangem.domain.models.wallet.UserWalletId internal fun String.toAccountId(userWalletId: UserWalletId): AccountId { return when { startsWith(AccountId.PaymentAccountIdPrefix) -> AccountId.forPaymentAccount(userWalletId).right() + startsWith(AccountId.VirtualAccountIdPrefix) -> AccountId.forVirtualAccount(userWalletId).right() else -> AccountId.forCryptoPortfolio(value = this, userWalletId = userWalletId) }.getOrElse { error("Unable to create AccountId from value: $this. Cause: $it") diff --git a/data/account/src/main/kotlin/com/tangem/data/account/producer/DefaultSingleAccountListProducer.kt b/data/account/src/main/kotlin/com/tangem/data/account/producer/DefaultSingleAccountListProducer.kt index 2b5ebd7563..f6f4bc2cc8 100644 --- a/data/account/src/main/kotlin/com/tangem/data/account/producer/DefaultSingleAccountListProducer.kt +++ b/data/account/src/main/kotlin/com/tangem/data/account/producer/DefaultSingleAccountListProducer.kt @@ -11,6 +11,7 @@ import com.tangem.domain.common.wallets.getSyncStrict import com.tangem.domain.core.flow.FlowProducerTools import com.tangem.domain.models.account.Account import com.tangem.domain.models.wallet.UserWallet +import com.tangem.features.virtualaccount.VirtualAccountFeatureToggles import com.tangem.hot.sdk.model.HotWalletId import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.logging.TangemLogger @@ -37,6 +38,7 @@ internal class DefaultSingleAccountListProducer @AssistedInject constructor( override val flowProducerTools: FlowProducerTools, private val walletAccountListFlowFactory: WalletAccountListFlowFactory, private val userWalletsListRepository: UserWalletsListRepository, + private val virtualAccountsFeatureToggles: VirtualAccountFeatureToggles, private val dispatchers: CoroutineDispatcherProvider, ) : SingleAccountListProducer { @@ -51,18 +53,9 @@ internal class DefaultSingleAccountListProducer @AssistedInject constructor( return walletAccountListFlowFactory.create(walletId) .map { accountList -> val userWallet = userWalletsListRepository.getSyncStrict(id = walletId) - val isPaymentSupported = userWallet.isPaymentAccountSupported() - logger.i( - "produce()[$walletId]: userWallet resolved (type=${userWallet::class.simpleName}), " + - "isPaymentAccountSupported=$isPaymentSupported", - ) - if (isPaymentSupported) { - accountList.plus(Account.Payment(walletId)).getOrElse { throwable -> - error("Can not combine account list and payment account status: $throwable") - } - } else { - accountList - } + accountList + .addAccountIf(userWallet.isPaymentAccountSupported()) { Account.Payment(walletId) } + .addAccountIf(userWallet.isVirtualAccountSupported()) { Account.Virtual(walletId) } } .flowOn(dispatchers.default) } @@ -72,6 +65,25 @@ internal class DefaultSingleAccountListProducer @AssistedInject constructor( is UserWallet.Hot -> hotWalletId.authType != HotWalletId.AuthType.NoPassword } + private fun UserWallet.isVirtualAccountSupported(): Boolean { + if (!virtualAccountsFeatureToggles.isVirtualAccountsEnabled) return false + + return when (this) { + is UserWallet.Cold -> scanResponse.card.firmwareVersion >= FirmwareVersion.HDWalletAvailable + is UserWallet.Hot -> hotWalletId.authType != HotWalletId.AuthType.NoPassword + } + } + + private inline fun AccountList.addAccountIf(condition: Boolean, account: () -> Account): AccountList { + return if (condition) { + plus(account()).getOrElse { throwable -> + error("Can not combine account list and special account: $throwable") + } + } else { + this + } + } + @AssistedFactory interface Factory : SingleAccountListProducer.Factory { override fun create(params: SingleAccountListProducer.Params): DefaultSingleAccountListProducer diff --git a/data/account/src/test/java/com/tangem/data/account/producer/DefaultSingleAccountListProducerTest.kt b/data/account/src/test/java/com/tangem/data/account/producer/DefaultSingleAccountListProducerTest.kt index d4138d111c..bc682f1160 100644 --- a/data/account/src/test/java/com/tangem/data/account/producer/DefaultSingleAccountListProducerTest.kt +++ b/data/account/src/test/java/com/tangem/data/account/producer/DefaultSingleAccountListProducerTest.kt @@ -1,13 +1,16 @@ package com.tangem.data.account.producer +import arrow.core.getOrElse import com.google.common.truth.Truth import com.tangem.domain.account.models.AccountList import com.tangem.domain.account.producer.SingleAccountListProducer import com.tangem.domain.common.wallets.UserWalletsListRepository import com.tangem.domain.core.flow.FlowProducerTools import com.tangem.domain.models.TokensSortType +import com.tangem.domain.models.account.Account import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.features.virtualaccount.VirtualAccountFeatureToggles import com.tangem.hot.sdk.model.HotWalletId import com.tangem.test.core.getEmittedValues import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider @@ -40,12 +43,16 @@ class DefaultSingleAccountListProducerTest { private val userWalletsListRepository = mockk { every { userWallets } returns MutableStateFlow?>(value = listOf(userWallet)) } + private val virtualAccountsFeatureToggles = mockk { + every { isVirtualAccountsEnabled } returns false + } private val producer = DefaultSingleAccountListProducer( params = SingleAccountListProducer.Params(userWalletId = userWalletId), walletAccountListFlowFactory = walletAccountListFlowFactory, flowProducerTools = flowProducerTools, userWalletsListRepository = userWalletsListRepository, + virtualAccountsFeatureToggles = virtualAccountsFeatureToggles, dispatchers = TestingCoroutineDispatcherProvider(), ) @@ -72,6 +79,45 @@ class DefaultSingleAccountListProducerTest { } } + @Test + fun `GIVEN virtual accounts enabled WHEN produce THEN account list contains virtual account`() = runTest { + // Arrange + val supportedWallet = mockk { + every { walletId } returns userWalletId + every { hotWalletId } returns mockk { + every { authType } returns HotWalletId.AuthType.Password + } + } + val userWalletsListRepository = mockk { + every { userWallets } returns MutableStateFlow?>(value = listOf(supportedWallet)) + } + val virtualAccountsFeatureToggles = mockk { + every { isVirtualAccountsEnabled } returns true + } + val producer = DefaultSingleAccountListProducer( + params = SingleAccountListProducer.Params(userWalletId = userWalletId), + walletAccountListFlowFactory = walletAccountListFlowFactory, + flowProducerTools = flowProducerTools, + userWalletsListRepository = userWalletsListRepository, + virtualAccountsFeatureToggles = virtualAccountsFeatureToggles, + dispatchers = TestingCoroutineDispatcherProvider(), + ) + + val accountList = AccountList.empty(userWalletId) + every { walletAccountListFlowFactory.create(userWalletId) } returns flowOf(accountList) + + // Act + val actual = producer.produce().let(::getEmittedValues) + + // Assert + val expected = accountList + .plus(Account.Payment(userWalletId)) + .getOrElse { error("Unable to add payment account: $it") } + .plus(Account.Virtual(userWalletId)) + .getOrElse { error("Unable to add virtual account: $it") } + Truth.assertThat(actual).containsExactly(expected) + } + @Test fun `flow will updated if factoryFlow is updated`() = runTest { // Arrange diff --git a/data/visa/src/main/kotlin/com/tangem/data/virtualaccount/converter/VirtualAccountStatusValueDMConverter.kt b/data/visa/src/main/kotlin/com/tangem/data/virtualaccount/converter/VirtualAccountStatusValueDMConverter.kt new file mode 100644 index 0000000000..737fc9ff21 --- /dev/null +++ b/data/visa/src/main/kotlin/com/tangem/data/virtualaccount/converter/VirtualAccountStatusValueDMConverter.kt @@ -0,0 +1,113 @@ +package com.tangem.data.virtualaccount.converter + +import com.tangem.datasource.local.visa.entity.VirtualAccountStatusValueDM +import com.tangem.domain.models.StatusSource +import com.tangem.domain.models.account.VirtualAccountStatusValue +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.pay.TangemPayCurrencyFactory +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Two-way converter between [VirtualAccountStatusValue] and [VirtualAccountStatusValueDM]. + * + * [convert] maps domain → data model. Returns null for transient statuses that should not be persisted + * (Loading, ExposedDevice, Unavailable, NotSynced). + * + * [convertBack] maps data model → domain. All restored statuses have [StatusSource.CACHE] as source. + */ +@Singleton +internal class VirtualAccountStatusValueDMConverter @Inject constructor( + private val tangemPayCurrencyFactory: TangemPayCurrencyFactory, +) { + + fun convert(value: VirtualAccountStatusValue): VirtualAccountStatusValueDM? { + return when (value) { + is VirtualAccountStatusValue.Empty -> VirtualAccountStatusValueDM.Empty() + is VirtualAccountStatusValue.NotCreated -> VirtualAccountStatusValueDM.NotCreated() + is VirtualAccountStatusValue.UnderReview -> VirtualAccountStatusValueDM.UnderReview( + kycStatus = value.kycStatus, + customerId = value.customerId, + ) + is VirtualAccountStatusValue.Provisioning -> VirtualAccountStatusValueDM.Provisioning() + is VirtualAccountStatusValue.CountryNotSupported -> VirtualAccountStatusValueDM.CountryNotSupported() + is VirtualAccountStatusValue.Active -> VirtualAccountStatusValueDM.ActiveAccount( + customerId = value.customerId, + currencyCode = value.currencyCode, + depositAddress = value.depositAddress, + fiatBalance = value.fiatBalance.toDM(), + cryptoBalance = value.cryptoBalance.toDM(), + fiatRate = value.fiatRate, + availableForWithdrawal = value.availableForWithdrawal, + ) + // Transient statuses are not persisted + is VirtualAccountStatusValue.Loading, + is VirtualAccountStatusValue.Error.ExposedDevice, + is VirtualAccountStatusValue.Error.Unavailable, + is VirtualAccountStatusValue.Error.NotSynced, + -> null + } + } + + fun convertBack(userWalletId: UserWalletId, value: VirtualAccountStatusValueDM?): VirtualAccountStatusValue { + return when (value) { + is VirtualAccountStatusValueDM.Empty -> VirtualAccountStatusValue.Empty + is VirtualAccountStatusValueDM.NotCreated -> VirtualAccountStatusValue.NotCreated + is VirtualAccountStatusValueDM.Provisioning -> VirtualAccountStatusValue.Provisioning( + source = StatusSource.CACHE, + ) + is VirtualAccountStatusValueDM.CountryNotSupported -> VirtualAccountStatusValue.CountryNotSupported + is VirtualAccountStatusValueDM.UnderReview -> VirtualAccountStatusValue.UnderReview( + source = StatusSource.CACHE, + kycStatus = value.kycStatus, + customerId = value.customerId, + ) + is VirtualAccountStatusValueDM.ActiveAccount -> VirtualAccountStatusValue.Active( + source = StatusSource.CACHE, + customerId = value.customerId, + currencyCode = value.currencyCode, + depositAddress = value.depositAddress, + fiatBalance = value.fiatBalance.toDomain(), + cryptoBalance = value.cryptoBalance.toDomain(), + availableForWithdrawal = value.availableForWithdrawal, + cryptoCurrency = tangemPayCurrencyFactory.create(userWalletId), + fiatRate = value.fiatRate, + ) + null -> VirtualAccountStatusValue.Error.Unavailable + } + } + + private fun VirtualAccountStatusValue.FiatBalance.toDM(): VirtualAccountStatusValueDM.FiatBalanceDM { + return VirtualAccountStatusValueDM.FiatBalanceDM( + availableBalance = availableBalance, + currency = currency, + ) + } + + private fun VirtualAccountStatusValue.CryptoBalance.toDM(): VirtualAccountStatusValueDM.CryptoBalanceDM { + return VirtualAccountStatusValueDM.CryptoBalanceDM( + id = id, + chainId = chainId, + depositAddress = depositAddress, + tokenContractAddress = tokenContractAddress, + balance = balance, + ) + } + + private fun VirtualAccountStatusValueDM.FiatBalanceDM.toDomain(): VirtualAccountStatusValue.FiatBalance { + return VirtualAccountStatusValue.FiatBalance( + availableBalance = availableBalance, + currency = currency, + ) + } + + private fun VirtualAccountStatusValueDM.CryptoBalanceDM.toDomain(): VirtualAccountStatusValue.CryptoBalance { + return VirtualAccountStatusValue.CryptoBalance( + id = id, + chainId = chainId, + depositAddress = depositAddress, + tokenContractAddress = tokenContractAddress, + balance = balance, + ) + } +} \ No newline at end of file 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 new file mode 100644 index 0000000000..117dff144b --- /dev/null +++ b/data/visa/src/main/kotlin/com/tangem/data/virtualaccount/di/VirtualAccountDataModule.kt @@ -0,0 +1,81 @@ +package com.tangem.data.virtualaccount.di + +import android.content.Context +import androidx.datastore.core.DataStoreFactory +import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler +import androidx.datastore.dataStoreFile +import com.squareup.moshi.Moshi +import com.tangem.data.virtualaccount.converter.VirtualAccountStatusValueDMConverter +import com.tangem.data.virtualaccount.flow.DefaultVirtualAccountStatusFetcher +import com.tangem.data.virtualaccount.flow.DefaultVirtualAccountStatusProducer +import com.tangem.data.virtualaccount.store.VirtualAccountStatusesStore +import com.tangem.datasource.di.NetworkMoshi +import com.tangem.datasource.local.datastore.RuntimeSharedStore +import com.tangem.datasource.local.visa.entity.VirtualAccountStatusValueDM +import com.tangem.datasource.utils.MoshiDataStoreSerializer +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.utils.coroutines.AppCoroutineScope +import dagger.Binds +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +internal interface VirtualAccountDataModule { + + @Binds + @Singleton + fun bindVirtualAccountStatusProducerFactory( + impl: DefaultVirtualAccountStatusProducer.Factory, + ): VirtualAccountStatusProducer.Factory + + @Binds + @Singleton + fun bindVirtualAccountStatusFetcher(impl: DefaultVirtualAccountStatusFetcher): VirtualAccountStatusFetcher + + companion object { + + @Provides + @Singleton + fun provideVirtualAccountStatusesStore( + @NetworkMoshi moshi: Moshi, + @ApplicationContext context: Context, + scope: AppCoroutineScope, + converter: VirtualAccountStatusValueDMConverter, + ): VirtualAccountStatusesStore { + return VirtualAccountStatusesStore( + runtimeStore = RuntimeSharedStore(), + persistenceDataStore = DataStoreFactory.create( + serializer = MoshiDataStoreSerializer( + moshi = moshi, + types = mapWithStringKeyTypes(), + defaultValue = emptyMap(), + ), + corruptionHandler = ReplaceFileCorruptionHandler { emptyMap() }, + produceFile = { context.dataStoreFile(fileName = "virtual_account_statuses") }, + scope = scope, + ), + converter = converter, + scope = scope, + ) + } + + @Provides + @Singleton + fun provideVirtualAccountStatusSupplier( + factory: VirtualAccountStatusProducer.Factory, + ): VirtualAccountStatusSupplier { + return object : VirtualAccountStatusSupplier( + factory = factory, + keyCreator = { "virtual_account_status_${it.userWalletId.stringValue}" }, + ) {} + } + } +} \ No newline at end of file diff --git a/data/visa/src/main/kotlin/com/tangem/data/virtualaccount/flow/DefaultVirtualAccountStatusFetcher.kt b/data/visa/src/main/kotlin/com/tangem/data/virtualaccount/flow/DefaultVirtualAccountStatusFetcher.kt new file mode 100644 index 0000000000..8a1643d193 --- /dev/null +++ b/data/visa/src/main/kotlin/com/tangem/data/virtualaccount/flow/DefaultVirtualAccountStatusFetcher.kt @@ -0,0 +1,34 @@ +package com.tangem.data.virtualaccount.flow + +import arrow.core.Either +import com.tangem.data.virtualaccount.store.VirtualAccountStatusesStore +import com.tangem.domain.core.utils.catchOn +import com.tangem.domain.models.StatusSource +import com.tangem.domain.models.account.Account +import com.tangem.domain.models.account.AccountStatus +import com.tangem.domain.models.account.VirtualAccountStatusValue +import com.tangem.domain.virtualaccount.flow.VirtualAccountStatusFetcher +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import javax.inject.Inject + +internal class DefaultVirtualAccountStatusFetcher @Inject constructor( + private val virtualAccountStatusesStore: VirtualAccountStatusesStore, + private val dispatchers: CoroutineDispatcherProvider, +) : VirtualAccountStatusFetcher { + + override suspend fun invoke(params: VirtualAccountStatusFetcher.Params) = Either.catchOn(dispatchers.default) { + val account = Account.Virtual(userWalletId = params.userWalletId) + // TODO([REDACTED_TASK_KEY]): Replace with the real VA status fetch (provisioning state, balance and banking + // details) from the backend once Virtual Account status endpoints are available. Until then the + // account is surfaced as NotCreated so the entity flows through the app end-to-end. + virtualAccountStatusesStore.store( + userWalletId = params.userWalletId, + status = AccountStatus.Virtual(account = account, value = VirtualAccountStatusValue.NotCreated), + ) + }.onLeft { + virtualAccountStatusesStore.updateStatusSource( + userWalletId = params.userWalletId, + source = StatusSource.ONLY_CACHE, + ) + } +} \ No newline at end of file diff --git a/data/visa/src/main/kotlin/com/tangem/data/virtualaccount/flow/DefaultVirtualAccountStatusProducer.kt b/data/visa/src/main/kotlin/com/tangem/data/virtualaccount/flow/DefaultVirtualAccountStatusProducer.kt new file mode 100644 index 0000000000..96d2cd7cc0 --- /dev/null +++ b/data/visa/src/main/kotlin/com/tangem/data/virtualaccount/flow/DefaultVirtualAccountStatusProducer.kt @@ -0,0 +1,61 @@ +package com.tangem.data.virtualaccount.flow + +import arrow.core.Option +import arrow.core.some +import com.tangem.data.virtualaccount.store.VirtualAccountStatusesStore +import com.tangem.domain.core.flow.FlowProducerTools +import com.tangem.domain.models.account.Account +import com.tangem.domain.models.account.AccountStatus +import com.tangem.domain.models.account.VirtualAccountStatusValue +import com.tangem.domain.virtualaccount.flow.VirtualAccountStatusProducer +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import com.tangem.utils.logging.TangemLogger +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map + +internal class DefaultVirtualAccountStatusProducer @AssistedInject constructor( + @Assisted private val params: VirtualAccountStatusProducer.Params, + override val flowProducerTools: FlowProducerTools, + private val virtualAccountStatusesStore: VirtualAccountStatusesStore, + private val dispatchers: CoroutineDispatcherProvider, +) : VirtualAccountStatusProducer { + + private val logger = TangemLogger.withTag(TAG) + private val account = Account.Virtual(userWalletId = params.userWalletId) + + override val fallback: Option + get() = AccountStatus.Virtual(account = account, value = VirtualAccountStatusValue.Error.Unavailable).some() + + override fun produce(): Flow { + return virtualAccountStatusesStore.get(userWalletId = params.userWalletId) + .map { status -> + if (status != null) { + logger.i("[${params.userWalletId}] flow emits statusType=${status.value::class.simpleName}") + AccountStatus.Virtual( + account = account, + value = status.value, + ) + } else { + logger.i("[${params.userWalletId}] status is null: emitting Empty fallback") + AccountStatus.Virtual( + account = account, + value = VirtualAccountStatusValue.Empty, + ) + } + } + .flowOn(dispatchers.default) + } + + @AssistedFactory + interface Factory : VirtualAccountStatusProducer.Factory { + override fun create(params: VirtualAccountStatusProducer.Params): DefaultVirtualAccountStatusProducer + } + + private companion object { + private const val TAG = "VirtualAccountStatusProducer" + } +} \ No newline at end of file diff --git a/data/visa/src/main/kotlin/com/tangem/data/virtualaccount/store/VirtualAccountStatusesStore.kt b/data/visa/src/main/kotlin/com/tangem/data/virtualaccount/store/VirtualAccountStatusesStore.kt new file mode 100644 index 0000000000..f5c57416f6 --- /dev/null +++ b/data/visa/src/main/kotlin/com/tangem/data/virtualaccount/store/VirtualAccountStatusesStore.kt @@ -0,0 +1,112 @@ +package com.tangem.data.virtualaccount.store + +import androidx.datastore.core.DataStore +import com.tangem.data.virtualaccount.converter.VirtualAccountStatusValueDMConverter +import com.tangem.datasource.local.datastore.RuntimeSharedStore +import com.tangem.datasource.local.visa.entity.VirtualAccountStatusValueDM +import com.tangem.domain.models.StatusSource +import com.tangem.domain.models.account.Account +import com.tangem.domain.models.account.AccountStatus +import com.tangem.domain.models.account.VirtualAccountStatusValue +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.utils.coroutines.AppCoroutineScope +import com.tangem.utils.coroutines.runSuspendCatching +import com.tangem.utils.logging.TangemLogger +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.launch + +internal typealias WalletIdWithVirtualStatus = Map +internal typealias WalletIdWithVirtualStatusDM = Map + +/** + * Store for virtual account statuses with dual storage (runtime + persistence). + * + * @property runtimeStore runtime store for fast in-memory access + * @property persistenceDataStore persistence store for caching across app restarts + */ +internal class VirtualAccountStatusesStore( + private val runtimeStore: RuntimeSharedStore, + private val persistenceDataStore: DataStore, + private val converter: VirtualAccountStatusValueDMConverter, + scope: AppCoroutineScope, +) { + + private val logger = TangemLogger.withTag(TAG) + + init { + scope.launch { + try { + val cachedStatuses = persistenceDataStore.data.firstOrNull() ?: return@launch + runtimeStore.store( + value = cachedStatuses.mapValues { (rawUserWalletId, statusDM) -> + val account = Account.Virtual(userWalletId = UserWalletId(rawUserWalletId)) + val statusValue = converter.convertBack(userWalletId = account.userWalletId, value = statusDM) + AccountStatus.Virtual(account = account, value = statusValue) + }, + ) + } catch (e: Exception) { + runSuspendCatching { persistenceDataStore.updateData { emptyMap() } } + logger.e("Error while loading cached virtual account statuses", e) + } + } + } + + fun get(userWalletId: UserWalletId): Flow { + return runtimeStore.get() + .onStart { logger.i("get($userWalletId): subscribed to runtimeStore") } + .onEach { map -> + logger.i( + "get($userWalletId): runtimeStore emitted map size=${map.size}, " + + "hasEntry=${map.containsKey(userWalletId.stringValue)}", + ) + } + .map { it[userWalletId.stringValue] } + } + + suspend fun getSyncOrNull(userWalletId: UserWalletId): AccountStatus.Virtual? { + return runtimeStore.getSyncOrNull()?.get(userWalletId.stringValue) + } + + suspend fun updateStatusSource(userWalletId: UserWalletId, source: StatusSource) { + runtimeStore.update(emptyMap()) { stored -> + stored.toMutableMap().apply { + val status = this[userWalletId.stringValue] ?: return@update stored + val newValue = status.copy(value = status.value.copySealed(source = source)) + put(key = userWalletId.stringValue, value = newValue) + } + } + } + + suspend fun store(userWalletId: UserWalletId, status: AccountStatus.Virtual) { + coroutineScope { + launch { storeInRuntime(userWalletId = userWalletId, status = status) } + launch { storeInPersistence(userWalletId = userWalletId, status = status.value) } + } + } + + suspend fun contains(userWalletId: UserWalletId): Boolean { + return runtimeStore.getSyncOrDefault(emptyMap()).containsKey(userWalletId.stringValue) + } + + private suspend fun storeInRuntime(userWalletId: UserWalletId, status: AccountStatus.Virtual) { + runtimeStore.update(default = emptyMap()) { stored -> + stored.toMutableMap().apply { + put(key = userWalletId.stringValue, value = status) + } + } + } + + private suspend fun storeInPersistence(userWalletId: UserWalletId, status: VirtualAccountStatusValue) { + val statusDM = converter.convert(value = status) ?: return + persistenceDataStore.updateData { storedStatuses -> + storedStatuses.toMutableMap().apply { + put(key = userWalletId.stringValue, value = statusDM) + } + } + } + + private companion object { + private const val TAG = "VirtualAccountStatusesStore" + } +} \ No newline at end of file diff --git a/domain/account/status/src/main/java/com/tangem/domain/account/status/producer/DefaultSingleAccountStatusListProducer.kt b/domain/account/status/src/main/java/com/tangem/domain/account/status/producer/DefaultSingleAccountStatusListProducer.kt index 88b02761a8..75fcbfe749 100644 --- a/domain/account/status/src/main/java/com/tangem/domain/account/status/producer/DefaultSingleAccountStatusListProducer.kt +++ b/domain/account/status/src/main/java/com/tangem/domain/account/status/producer/DefaultSingleAccountStatusListProducer.kt @@ -3,7 +3,6 @@ package com.tangem.domain.account.status.producer import arrow.core.Option import arrow.core.none import arrow.core.toOption -import com.tangem.common.card.FirmwareVersion import com.tangem.core.analytics.api.AnalyticsExceptionHandler import com.tangem.domain.account.models.AccountCurrencyId import com.tangem.domain.account.models.AccountList @@ -16,9 +15,7 @@ import com.tangem.domain.core.utils.lceContent import com.tangem.domain.core.utils.lceLoading import com.tangem.domain.models.StatusSource import com.tangem.domain.models.TotalFiatBalance -import com.tangem.domain.models.account.Account -import com.tangem.domain.models.account.AccountStatus -import com.tangem.domain.models.account.VirtualAccountStatusValue +import com.tangem.domain.models.account.* import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.network.Network @@ -45,7 +42,7 @@ import com.tangem.domain.tokens.operations.CryptoCurrencyStatusFactory import com.tangem.domain.tokens.operations.PriceChangeCalculator import com.tangem.domain.tokens.operations.TokenListFactory import com.tangem.domain.tokens.operations.TotalFiatBalanceCalculator -import com.tangem.hot.sdk.model.HotWalletId +import com.tangem.domain.virtualaccount.flow.VirtualAccountStatusSupplier import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.logging.TangemLogger import dagger.assisted.Assisted @@ -83,6 +80,7 @@ internal class DefaultSingleAccountStatusListProducer @AssistedInject constructo private val userWalletsListRepository: UserWalletsListRepository, private val singleAccountListSupplier: SingleAccountListSupplier, private val paymentAccountStatusSupplier: PaymentAccountStatusSupplier, + private val virtualAccountStatusSupplier: VirtualAccountStatusSupplier, private val networksRepository: NetworksRepository, private val dispatchers: CoroutineDispatcherProvider, private val networkStatusSupplier: MultiNetworkStatusSupplier, @@ -93,8 +91,8 @@ internal class DefaultSingleAccountStatusListProducer @AssistedInject constructo ) : SingleAccountStatusListProducer { private val logger = TangemLogger.withTag(TAG) - - // VirtualAccount status pipeline lands in a follow-up PR; until then surface an unavailable status. + private val Account.Payment.errorPaymentAccountStatus: AccountStatus.Payment + get() = AccountStatus.Payment(this, PaymentAccountStatusValue.Error.Unavailable) private val Account.Virtual.errorVirtualAccountStatus: AccountStatus.Virtual get() = AccountStatus.Virtual(this, VirtualAccountStatusValue.Error.Unavailable) @@ -148,141 +146,100 @@ internal class DefaultSingleAccountStatusListProducer @AssistedInject constructo flattenCurrency = flattenCurrency, ) - val isPaymentSupported = userWallet.isPaymentAccountSupported() - logger.i("flattenFlow[$walletId]: isPaymentAccountSupported=$isPaymentSupported") - if (isPaymentSupported) { - combineWithPaymentAccount( - accountListFlow = accountListFlow, - cryptoCurrencyStatusFlow = cryptoCurrencyStatusFlow, - paymentAccountStatusFlow = paymentAccountStatusSupplier.invoke(userWalletId = params.userWalletId) - .onEach { paymentStatus -> - logger.i( - "flattenFlow[$walletId]: paymentAccountStatus emitted " + - "valueType=${paymentStatus.value::class.simpleName}", - ) - }, - ) - } else { - combineWithoutPaymentAccount( - accountListFlow = accountListFlow, - cryptoCurrencyStatusFlow = cryptoCurrencyStatusFlow, - ) - } - .collect { accountStatusList -> channel.send(accountStatusList) } - } - - private fun combineWithPaymentAccount( - accountListFlow: StateFlow, - cryptoCurrencyStatusFlow: Flow>, - paymentAccountStatusFlow: Flow, - ): Flow { - return combine( - flow = accountListFlow, - flow2 = cryptoCurrencyStatusFlow, - flow3 = paymentAccountStatusFlow, - transform = { accountList, currencyStatusMap, paymentAccountStatus -> - logger.i( - "combineWithPayment[${params.userWalletId}] transform: " + - "accounts=${accountList.accounts.size}, " + - "currencyStatusMap=${currencyStatusMap.size}, " + - "paymentType=${paymentAccountStatus.value::class.simpleName}", - ) - val accountStatuses = accountList.accounts.map { account -> - when (account) { - is Account.Payment -> paymentAccountStatus - is Account.Virtual -> account.errorVirtualAccountStatus - is Account.CryptoPortfolio -> if (account.cryptoCurrencies.isEmpty()) { - account.toEmptyAccountStatus() - } else { - val statuses: List = - account.cryptoCurrencies.map { currency -> - val acId = account.accountId to currency.id - currencyStatusMap[acId] ?: currency.toLoadingCurrencyStatus() - } - AccountStatus.CryptoPortfolio( - account = account, - tokenList = TokenListFactory.create( - statuses = statuses, - groupType = accountList.groupType, - sortType = accountList.sortType, - ), - priceChangeLce = PriceChangeCalculator.calculate(statuses = statuses), + val accounts = accountListFlow.value.accounts + val hasPaymentAccount = accounts.any { it is Account.Payment } + val hasVirtualAccount = accounts.any { it is Account.Virtual } + logger.i("flattenFlow[$walletId]: payment=$hasPaymentAccount, virtual=$hasVirtualAccount") + val specialStatusFlows = buildList> { + if (hasPaymentAccount) { + add( + paymentAccountStatusSupplier.invoke(userWalletId = walletId) + .onEach { status -> + logger.i( + "flattenFlow[$walletId]: paymentAccountStatus emitted " + + "valueType=${status.value::class.simpleName}", ) - } - } + }, + ) + } + if (hasVirtualAccount) { + add( + virtualAccountStatusSupplier.invoke(userWalletId = walletId) + .onEach { status -> + logger.i( + "flattenFlow[$walletId]: virtualAccountStatus emitted " + + "valueType=${status.value::class.simpleName}", + ) + }, + ) + } + } + val specialStatusesFlow: Flow> = if (specialStatusFlows.isEmpty()) { + flowOf(emptyMap()) + } else { + combine(specialStatusFlows) { statuses -> statuses.associateBy(AccountStatus::accountId) } + } + + combine( + accountListFlow, + cryptoCurrencyStatusFlow, + specialStatusesFlow, + ) { accountList, currencyStatusMap, specialStatuses -> + logger.i( + "combine[$walletId] transform:" + + "accounts=${accountList.accounts.size}, " + + "currencyStatusMap=${currencyStatusMap.size}, " + + "specialStatuses=${specialStatuses.size}", + ) + val accountStatuses = accountList.accounts.map { account -> + when (account) { + is Account.CryptoPortfolio -> buildCryptoPortfolioStatus(account, currencyStatusMap, accountList) + is Account.Payment -> specialStatuses[account.accountId] ?: account.errorPaymentAccountStatus + is Account.Virtual -> specialStatuses[account.accountId] ?: account.errorVirtualAccountStatus } - val balances = accountStatuses.flattenTotalFiatBalance() + } + buildAccountStatusList(accountList = accountList, accountStatuses = accountStatuses) + }.collect { accountStatusList -> channel.send(accountStatusList) } + } - AccountStatusList( - userWalletId = accountList.userWalletId, - accountStatuses = accountStatuses, - totalAccounts = accountList.totalAccounts, - totalFiatBalance = TotalFiatBalanceCalculator.calculate(balances), - totalArchivedAccounts = accountList.totalArchivedAccounts, - sortType = accountList.sortType, - groupType = accountList.groupType, - ) - }, + private fun buildCryptoPortfolioStatus( + account: Account.CryptoPortfolio, + currencyStatusMap: Map, + accountList: AccountList, + ): AccountStatus.CryptoPortfolio { + if (account.cryptoCurrencies.isEmpty()) return account.toEmptyAccountStatus() + + val statuses: List = account.cryptoCurrencies.map { currency -> + val acId = account.accountId to currency.id + currencyStatusMap[acId] ?: currency.toLoadingCurrencyStatus() + } + return AccountStatus.CryptoPortfolio( + account = account, + tokenList = TokenListFactory.create( + statuses = statuses, + groupType = accountList.groupType, + sortType = accountList.sortType, + ), + priceChangeLce = PriceChangeCalculator.calculate(statuses = statuses), ) } - private fun combineWithoutPaymentAccount( - accountListFlow: StateFlow, - cryptoCurrencyStatusFlow: Flow>, - ): Flow { - return combine( - flow = accountListFlow, - flow2 = cryptoCurrencyStatusFlow, - transform = { accountList, currencyStatusMap -> - logger.i( - "combineWithoutPayment[${params.userWalletId}] transform: " + - "accounts=${accountList.accounts.size}, " + - "currencyStatusMap=${currencyStatusMap.size}", - ) - val accountStatuses = accountList.accounts - .filterIsInstance() - .map { account -> - when (account) { - is Account.CryptoPortfolio -> if (account.cryptoCurrencies.isEmpty()) { - account.toEmptyAccountStatus() - } else { - val statuses: List = - account.cryptoCurrencies.map { currency -> - val acId = account.accountId to currency.id - currencyStatusMap[acId] ?: currency.toLoadingCurrencyStatus() - } - AccountStatus.CryptoPortfolio( - account = account, - tokenList = TokenListFactory.create( - statuses = statuses, - groupType = accountList.groupType, - sortType = accountList.sortType, - ), - priceChangeLce = PriceChangeCalculator.calculate(statuses = statuses), - ) - } - } - } - val balances = accountStatuses.flattenTotalFiatBalance() - - AccountStatusList( - userWalletId = accountList.userWalletId, - accountStatuses = accountStatuses, - totalAccounts = accountList.totalAccounts, - totalFiatBalance = TotalFiatBalanceCalculator.calculate(balances), - totalArchivedAccounts = accountList.totalArchivedAccounts, - sortType = accountList.sortType, - groupType = accountList.groupType, - ) - }, + private fun buildAccountStatusList( + accountList: AccountList, + accountStatuses: List, + ): AccountStatusList { + val balances = accountStatuses.flattenTotalFiatBalance() + return AccountStatusList( + userWalletId = accountList.userWalletId, + accountStatuses = accountStatuses, + totalAccounts = accountList.totalAccounts, + totalFiatBalance = TotalFiatBalanceCalculator.calculate(balances), + totalArchivedAccounts = accountList.totalArchivedAccounts, + sortType = accountList.sortType, + groupType = accountList.groupType, ) } - private fun UserWallet.isPaymentAccountSupported(): Boolean = when (this) { - is UserWallet.Cold -> scanResponse.card.firmwareVersion >= FirmwareVersion.HDWalletAvailable - is UserWallet.Hot -> hotWalletId.authType != HotWalletId.AuthType.NoPassword - } - private fun ProducerScope.flattenCurrencyStatusFlow( userWallet: UserWallet, flattenCurrency: MutableSharedFlow>, diff --git a/domain/tokens/build.gradle.kts b/domain/tokens/build.gradle.kts index 9c0888258e..c13e8ebf7b 100644 --- a/domain/tokens/build.gradle.kts +++ b/domain/tokens/build.gradle.kts @@ -39,6 +39,7 @@ dependencies { implementation(projects.features.staking.api) implementation(projects.features.markets.api) implementation(projects.features.swap.api) + implementation(projects.features.virtualAccounts.details.api) //VIRTUAL_ACCOUNTS_ENABLED /** Project - Other */ implementation(projects.core.configToggles) diff --git a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/wallet/WalletBalanceFetcher.kt b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/wallet/WalletBalanceFetcher.kt index f4345e9c39..d33309c281 100644 --- a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/wallet/WalletBalanceFetcher.kt +++ b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/wallet/WalletBalanceFetcher.kt @@ -25,6 +25,8 @@ import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesSupplier import com.tangem.domain.tokens.wallet.implementor.MultiWalletBalanceFetcher import com.tangem.domain.tokens.wallet.implementor.SingleWalletBalanceFetcher import com.tangem.domain.tokens.wallet.implementor.SingleWalletWithTokenBalanceFetcher +import com.tangem.domain.virtualaccount.flow.VirtualAccountStatusFetcher +import com.tangem.features.virtualaccount.VirtualAccountFeatureToggles import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.logging.TangemLogger import kotlinx.coroutines.async @@ -56,6 +58,8 @@ class WalletBalanceFetcher internal constructor( private val singleWalletBalanceFetcher: BaseWalletBalanceFetcher, private val balanceFetchingOperations: BalanceFetchingOperations, private val paymentAccountStatusFetcher: PaymentAccountStatusFetcher, + private val virtualAccountStatusFetcher: VirtualAccountStatusFetcher, + private val virtualAccountsFeatureToggles: VirtualAccountFeatureToggles, private val dispatchers: CoroutineDispatcherProvider, ) : FlowFetcher { @@ -70,7 +74,9 @@ class WalletBalanceFetcher internal constructor( multiQuoteStatusFetcher: MultiQuoteStatusFetcher, multiStakingBalanceFetcher: MultiStakingBalanceFetcher, paymentAccountStatusFetcher: PaymentAccountStatusFetcher, + virtualAccountStatusFetcher: VirtualAccountStatusFetcher, stakingIdFactory: StakingIdFactory, + virtualAccountsFeatureToggles: VirtualAccountFeatureToggles, dispatchers: CoroutineDispatcherProvider, ) : this( userWalletsListRepository = userWalletsListRepository, @@ -85,6 +91,8 @@ class WalletBalanceFetcher internal constructor( stakingIdFactory = stakingIdFactory, ), paymentAccountStatusFetcher = paymentAccountStatusFetcher, + virtualAccountStatusFetcher = virtualAccountStatusFetcher, + virtualAccountsFeatureToggles = virtualAccountsFeatureToggles, dispatchers = dispatchers, ) @@ -99,6 +107,8 @@ class WalletBalanceFetcher internal constructor( multiQuoteStatusFetcher: MultiQuoteStatusFetcher, multiStakingBalanceFetcher: MultiStakingBalanceFetcher, paymentAccountStatusFetcher: PaymentAccountStatusFetcher, + virtualAccountStatusFetcher: VirtualAccountStatusFetcher, + virtualAccountsFeatureToggles: VirtualAccountFeatureToggles, stakingIdFactory: StakingIdFactory, dispatchers: CoroutineDispatcherProvider, ) : this( @@ -121,6 +131,8 @@ class WalletBalanceFetcher internal constructor( stakingIdFactory = stakingIdFactory, ), paymentAccountStatusFetcher = paymentAccountStatusFetcher, + virtualAccountStatusFetcher = virtualAccountStatusFetcher, + virtualAccountsFeatureToggles = virtualAccountsFeatureToggles, dispatchers = dispatchers, ) @@ -177,6 +189,14 @@ class WalletBalanceFetcher internal constructor( balanceFetchingOperations.fetchQuotes(rawCurrencyIds = setOf(TangemPayCurrencyFactory.TOKEN_ID)) paymentAccountStatusFetcher.invoke(PaymentAccountStatusFetcher.Params(userWalletId)) } + + // Fetch Virtual account separately for the same reason as TangemPay + if ( + fetchingSources.any { it is WalletFetchingSource.VirtualAccount } && + virtualAccountsFeatureToggles.isVirtualAccountsEnabled + ) { + virtualAccountStatusFetcher.invoke(VirtualAccountStatusFetcher.Params(userWalletId)) + } } } diff --git a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/wallet/WalletFetchingSource.kt b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/wallet/WalletFetchingSource.kt index 56d00faf1e..2fc5218289 100644 --- a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/wallet/WalletFetchingSource.kt +++ b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/wallet/WalletFetchingSource.kt @@ -18,6 +18,13 @@ sealed class WalletFetchingSource { */ data object TangemPay : WalletFetchingSource() + /** + * Virtual account fetching source. + * Handled separately from standard balance sources via + * [com.tangem.domain.virtualaccount.flow.VirtualAccountStatusFetcher]. + */ + data object VirtualAccount : WalletFetchingSource() + /** * Standard balance fetching sources (NETWORK, QUOTE, STAKING). * Processed via [BalanceFetchingOperations.fetchAll]. diff --git a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/wallet/implementor/MultiWalletBalanceFetcher.kt b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/wallet/implementor/MultiWalletBalanceFetcher.kt index 4bbf8f3557..24e9fa0b52 100644 --- a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/wallet/implementor/MultiWalletBalanceFetcher.kt +++ b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/wallet/implementor/MultiWalletBalanceFetcher.kt @@ -29,6 +29,7 @@ internal class MultiWalletBalanceFetcher( sources = setOf(FetchingSource.NETWORK, FetchingSource.QUOTE, FetchingSource.STAKING), ), WalletFetchingSource.TangemPay, + WalletFetchingSource.VirtualAccount, ) override suspend fun getCryptoCurrencies(userWallet: UserWallet): Set { diff --git a/domain/tokens/src/test/kotlin/com/tangem/domain/tokens/wallet/WalletBalanceFetcherTest.kt b/domain/tokens/src/test/kotlin/com/tangem/domain/tokens/wallet/WalletBalanceFetcherTest.kt index a345573819..51869fefec 100644 --- a/domain/tokens/src/test/kotlin/com/tangem/domain/tokens/wallet/WalletBalanceFetcherTest.kt +++ b/domain/tokens/src/test/kotlin/com/tangem/domain/tokens/wallet/WalletBalanceFetcherTest.kt @@ -23,6 +23,8 @@ import com.tangem.domain.tokens.FetchingSource import com.tangem.domain.tokens.wallet.implementor.MultiWalletBalanceFetcher import com.tangem.domain.tokens.wallet.implementor.SingleWalletBalanceFetcher import com.tangem.domain.tokens.wallet.implementor.SingleWalletWithTokenBalanceFetcher +import com.tangem.domain.virtualaccount.flow.VirtualAccountStatusFetcher +import com.tangem.features.virtualaccount.VirtualAccountFeatureToggles import com.tangem.test.core.assertEither import com.tangem.test.core.assertEitherRight import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider @@ -50,7 +52,11 @@ internal class WalletBalanceFetcherTest { private val multiQuoteStatusFetcher: MultiQuoteStatusFetcher = mockk() private val multiStakingBalanceFetcher: MultiStakingBalanceFetcher = mockk() private val paymentAccountStatusFetcher: PaymentAccountStatusFetcher = mockk() + private val virtualAccountStatusFetcher: VirtualAccountStatusFetcher = mockk() private val stakingIdFactory: StakingIdFactory = mockk() + private val virtualAccountsFeatureToggles: VirtualAccountFeatureToggles = mockk { + every { isVirtualAccountsEnabled } returns false + } private val fetcher = WalletBalanceFetcher( userWalletsListRepository = userWalletsListRepository, @@ -62,7 +68,9 @@ internal class WalletBalanceFetcherTest { multiQuoteStatusFetcher = multiQuoteStatusFetcher, multiStakingBalanceFetcher = multiStakingBalanceFetcher, paymentAccountStatusFetcher = paymentAccountStatusFetcher, + virtualAccountStatusFetcher = virtualAccountStatusFetcher, stakingIdFactory = stakingIdFactory, + virtualAccountsFeatureToggles = virtualAccountsFeatureToggles, dispatchers = TestingCoroutineDispatcherProvider(), ) diff --git a/domain/tokens/src/test/kotlin/com/tangem/domain/tokens/wallet/implementor/MultiWalletBalanceFetcherTest.kt b/domain/tokens/src/test/kotlin/com/tangem/domain/tokens/wallet/implementor/MultiWalletBalanceFetcherTest.kt index 0eac725b25..fb2880aa4a 100644 --- a/domain/tokens/src/test/kotlin/com/tangem/domain/tokens/wallet/implementor/MultiWalletBalanceFetcherTest.kt +++ b/domain/tokens/src/test/kotlin/com/tangem/domain/tokens/wallet/implementor/MultiWalletBalanceFetcherTest.kt @@ -7,10 +7,10 @@ import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.tokens.FetchingSource import com.tangem.domain.tokens.MultiWalletAccountListFetcher import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesProducer import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesSupplier -import com.tangem.domain.tokens.FetchingSource import com.tangem.domain.tokens.wallet.WalletFetchingSource import io.mockk.clearMocks import io.mockk.coEvery @@ -54,6 +54,7 @@ class MultiWalletBalanceFetcherTest { sources = setOf(FetchingSource.NETWORK, FetchingSource.QUOTE, FetchingSource.STAKING), ), WalletFetchingSource.TangemPay, + WalletFetchingSource.VirtualAccount, ) Truth.assertThat(actual).isEqualTo(expected) } diff --git a/domain/visa/src/main/kotlin/com/tangem/domain/virtualaccount/flow/VirtualAccountStatusFetcher.kt b/domain/visa/src/main/kotlin/com/tangem/domain/virtualaccount/flow/VirtualAccountStatusFetcher.kt new file mode 100644 index 0000000000..c444a35015 --- /dev/null +++ b/domain/visa/src/main/kotlin/com/tangem/domain/virtualaccount/flow/VirtualAccountStatusFetcher.kt @@ -0,0 +1,14 @@ +package com.tangem.domain.virtualaccount.flow + +import arrow.core.Either +import com.tangem.domain.core.flow.FlowFetcher +import com.tangem.domain.models.wallet.UserWalletId + +interface VirtualAccountStatusFetcher : FlowFetcher { + + suspend operator fun invoke(userWalletId: UserWalletId): Either { + return invoke(Params(userWalletId)) + } + + data class Params(val userWalletId: UserWalletId) +} \ No newline at end of file diff --git a/domain/visa/src/main/kotlin/com/tangem/domain/virtualaccount/flow/VirtualAccountStatusProducer.kt b/domain/visa/src/main/kotlin/com/tangem/domain/virtualaccount/flow/VirtualAccountStatusProducer.kt new file mode 100644 index 0000000000..45a6c1cc28 --- /dev/null +++ b/domain/visa/src/main/kotlin/com/tangem/domain/virtualaccount/flow/VirtualAccountStatusProducer.kt @@ -0,0 +1,11 @@ +package com.tangem.domain.virtualaccount.flow + +import com.tangem.domain.core.flow.FlowProducer +import com.tangem.domain.models.account.AccountStatus +import com.tangem.domain.models.wallet.UserWalletId + +interface VirtualAccountStatusProducer : FlowProducer { + data class Params(val userWalletId: UserWalletId) + + interface Factory : FlowProducer.Factory +} \ No newline at end of file diff --git a/domain/visa/src/main/kotlin/com/tangem/domain/virtualaccount/flow/VirtualAccountStatusSupplier.kt b/domain/visa/src/main/kotlin/com/tangem/domain/virtualaccount/flow/VirtualAccountStatusSupplier.kt new file mode 100644 index 0000000000..c556943453 --- /dev/null +++ b/domain/visa/src/main/kotlin/com/tangem/domain/virtualaccount/flow/VirtualAccountStatusSupplier.kt @@ -0,0 +1,17 @@ +package com.tangem.domain.virtualaccount.flow + +import com.tangem.domain.core.flow.FlowCachingSupplier +import com.tangem.domain.models.account.AccountStatus +import com.tangem.domain.models.wallet.UserWalletId +import kotlinx.coroutines.flow.Flow + +open class VirtualAccountStatusSupplier( + override val factory: VirtualAccountStatusProducer.Factory, + override val keyCreator: (VirtualAccountStatusProducer.Params) -> String, +) : FlowCachingSupplier() { + + operator fun invoke(userWalletId: UserWalletId): Flow { + val params = VirtualAccountStatusProducer.Params(userWalletId) + return this.invoke(params) + } +} \ No newline at end of file