Updated on 2026-08-14

This commit is contained in:
Tangem 2026-06-08 16:03:08 +05:00
parent 24d7953389
commit 8683516c59
24 changed files with 817 additions and 148 deletions

View file

@ -316,6 +316,12 @@ dependencies {
implementation(projects.features.tangempay.main.impl) implementation(projects.features.tangempay.main.impl)
implementation(projects.features.tangempay.onboarding.api) implementation(projects.features.tangempay.onboarding.api)
implementation(projects.features.tangempay.onboarding.impl) 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.api)
implementation(projects.features.tokenRecieve.impl) implementation(projects.features.tokenRecieve.impl)
implementation(projects.features.yieldSupply.api) implementation(projects.features.yieldSupply.api)

View file

@ -1,6 +1,7 @@
package com.tangem.tap.di.domain package com.tangem.tap.di.domain
import com.tangem.core.configtoggle.feature.FeatureTogglesManager 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.account.supplier.SingleAccountListSupplier
import com.tangem.domain.common.tokens.CardCryptoCurrencyFactory import com.tangem.domain.common.tokens.CardCryptoCurrencyFactory
import com.tangem.domain.common.wallets.UserWalletsListRepository 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.repository.NetworksRepository
import com.tangem.domain.networks.single.SingleNetworkStatusFetcher import com.tangem.domain.networks.single.SingleNetworkStatusFetcher
import com.tangem.domain.pay.flow.PaymentAccountStatusFetcher import com.tangem.domain.pay.flow.PaymentAccountStatusFetcher
import com.tangem.domain.stories.StoriesRepository
import com.tangem.domain.quotes.multi.MultiQuoteStatusFetcher import com.tangem.domain.quotes.multi.MultiQuoteStatusFetcher
import com.tangem.domain.staking.StakingIdFactory import com.tangem.domain.staking.StakingIdFactory
import com.tangem.domain.staking.multi.MultiStakingBalanceFetcher import com.tangem.domain.staking.multi.MultiStakingBalanceFetcher
import com.tangem.domain.staking.repositories.StakingRepository 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.*
import com.tangem.domain.tokens.repository.CurrenciesRepository import com.tangem.domain.tokens.repository.CurrenciesRepository
import com.tangem.domain.tokens.repository.CurrencyChecksRepository import com.tangem.domain.tokens.repository.CurrencyChecksRepository
import com.tangem.domain.tokens.repository.TokenReceiveWarningsViewedRepository import com.tangem.domain.tokens.repository.TokenReceiveWarningsViewedRepository
import com.tangem.domain.tokens.repository.YieldSupplyWarningsViewedRepository import com.tangem.domain.tokens.repository.YieldSupplyWarningsViewedRepository
import com.tangem.domain.tokens.wallet.WalletBalanceFetcher import com.tangem.domain.tokens.wallet.WalletBalanceFetcher
import com.tangem.domain.virtualaccount.flow.VirtualAccountStatusFetcher
import com.tangem.domain.walletmanager.WalletManagersFacade import com.tangem.domain.walletmanager.WalletManagersFacade
import com.tangem.features.virtualaccount.VirtualAccountFeatureToggles
import com.tangem.tap.domain.tokens.DefaultTokensFeatureToggles import com.tangem.tap.domain.tokens.DefaultTokensFeatureToggles
import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import dagger.Module import dagger.Module
@ -162,6 +164,8 @@ internal object TokensDomainModule {
multiQuoteStatusFetcher: MultiQuoteStatusFetcher, multiQuoteStatusFetcher: MultiQuoteStatusFetcher,
multiStakingBalanceFetcher: MultiStakingBalanceFetcher, multiStakingBalanceFetcher: MultiStakingBalanceFetcher,
paymentAccountStatusFetcher: PaymentAccountStatusFetcher, paymentAccountStatusFetcher: PaymentAccountStatusFetcher,
virtualAccountStatusFetcher: VirtualAccountStatusFetcher,
virtualAccountsFeatureToggles: VirtualAccountFeatureToggles,
stakingIdFactory: StakingIdFactory, stakingIdFactory: StakingIdFactory,
dispatchers: CoroutineDispatcherProvider, dispatchers: CoroutineDispatcherProvider,
): WalletBalanceFetcher { ): WalletBalanceFetcher {
@ -175,6 +179,8 @@ internal object TokensDomainModule {
multiQuoteStatusFetcher = multiQuoteStatusFetcher, multiQuoteStatusFetcher = multiQuoteStatusFetcher,
multiStakingBalanceFetcher = multiStakingBalanceFetcher, multiStakingBalanceFetcher = multiStakingBalanceFetcher,
paymentAccountStatusFetcher = paymentAccountStatusFetcher, paymentAccountStatusFetcher = paymentAccountStatusFetcher,
virtualAccountStatusFetcher = virtualAccountStatusFetcher,
virtualAccountsFeatureToggles = virtualAccountsFeatureToggles,
stakingIdFactory = stakingIdFactory, stakingIdFactory = stakingIdFactory,
dispatchers = dispatchers, dispatchers = dispatchers,
) )

View file

@ -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.config.providers.models.ProviderModel
import com.tangem.datasource.local.network.entity.NetworkStatusDM import com.tangem.datasource.local.network.entity.NetworkStatusDM
import com.tangem.datasource.local.visa.entity.PaymentAccountStatusValueDM 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.datasource.utils.SerializeNullsFactory
import com.tangem.domain.models.scan.serialization.* import com.tangem.domain.models.scan.serialization.*
import dagger.Module import dagger.Module
@ -56,6 +57,18 @@ class MoshiModule {
.withSubtype(PaymentAccountStatusValueDM.DeactivatedAccount::class.java, "deactivated_account") .withSubtype(PaymentAccountStatusValueDM.DeactivatedAccount::class.java, "deactivated_account")
.withSubtype(PaymentAccountStatusValueDM.CardIssueFailed::class.java, "card_issue_failed"), .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( .add(
PolymorphicJsonAdapterFactory.of(NFTCollection.Identifier::class.java, "bc") PolymorphicJsonAdapterFactory.of(NFTCollection.Identifier::class.java, "bc")
.withSubtype(NFTCollection.Identifier.EVM::class.java, "evm") .withSubtype(NFTCollection.Identifier.EVM::class.java, "evm")

View file

@ -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,
)
}

View file

@ -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<VirtualAccountStatusValueDM>()
@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)
}
}

View file

@ -10,6 +10,8 @@ android {
} }
dependencies { dependencies {
implementation(projects.features.virtualAccounts.details.api) // VIRTUAL_ACCOUNTS_ENABLED
// region Project - Common // region Project - Common
implementation(projects.common.ui) // It's needed for getting AccountName.DefaultMain value implementation(projects.common.ui) // It's needed for getting AccountName.DefaultMain value
// endregion // endregion

View file

@ -11,6 +11,7 @@ import com.tangem.domain.models.wallet.UserWalletId
internal fun String.toAccountId(userWalletId: UserWalletId): AccountId { internal fun String.toAccountId(userWalletId: UserWalletId): AccountId {
return when { return when {
startsWith(AccountId.PaymentAccountIdPrefix) -> AccountId.forPaymentAccount(userWalletId).right() startsWith(AccountId.PaymentAccountIdPrefix) -> AccountId.forPaymentAccount(userWalletId).right()
startsWith(AccountId.VirtualAccountIdPrefix) -> AccountId.forVirtualAccount(userWalletId).right()
else -> AccountId.forCryptoPortfolio(value = this, userWalletId = userWalletId) else -> AccountId.forCryptoPortfolio(value = this, userWalletId = userWalletId)
}.getOrElse { }.getOrElse {
error("Unable to create AccountId from value: $this. Cause: $it") error("Unable to create AccountId from value: $this. Cause: $it")

View file

@ -11,6 +11,7 @@ import com.tangem.domain.common.wallets.getSyncStrict
import com.tangem.domain.core.flow.FlowProducerTools import com.tangem.domain.core.flow.FlowProducerTools
import com.tangem.domain.models.account.Account import com.tangem.domain.models.account.Account
import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWallet
import com.tangem.features.virtualaccount.VirtualAccountFeatureToggles
import com.tangem.hot.sdk.model.HotWalletId import com.tangem.hot.sdk.model.HotWalletId
import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.logging.TangemLogger import com.tangem.utils.logging.TangemLogger
@ -37,6 +38,7 @@ internal class DefaultSingleAccountListProducer @AssistedInject constructor(
override val flowProducerTools: FlowProducerTools, override val flowProducerTools: FlowProducerTools,
private val walletAccountListFlowFactory: WalletAccountListFlowFactory, private val walletAccountListFlowFactory: WalletAccountListFlowFactory,
private val userWalletsListRepository: UserWalletsListRepository, private val userWalletsListRepository: UserWalletsListRepository,
private val virtualAccountsFeatureToggles: VirtualAccountFeatureToggles,
private val dispatchers: CoroutineDispatcherProvider, private val dispatchers: CoroutineDispatcherProvider,
) : SingleAccountListProducer { ) : SingleAccountListProducer {
@ -51,18 +53,9 @@ internal class DefaultSingleAccountListProducer @AssistedInject constructor(
return walletAccountListFlowFactory.create(walletId) return walletAccountListFlowFactory.create(walletId)
.map { accountList -> .map { accountList ->
val userWallet = userWalletsListRepository.getSyncStrict(id = walletId) val userWallet = userWalletsListRepository.getSyncStrict(id = walletId)
val isPaymentSupported = userWallet.isPaymentAccountSupported() accountList
logger.i( .addAccountIf(userWallet.isPaymentAccountSupported()) { Account.Payment(walletId) }
"produce()[$walletId]: userWallet resolved (type=${userWallet::class.simpleName}), " + .addAccountIf(userWallet.isVirtualAccountSupported()) { Account.Virtual(walletId) }
"isPaymentAccountSupported=$isPaymentSupported",
)
if (isPaymentSupported) {
accountList.plus(Account.Payment(walletId)).getOrElse { throwable ->
error("Can not combine account list and payment account status: $throwable")
}
} else {
accountList
}
} }
.flowOn(dispatchers.default) .flowOn(dispatchers.default)
} }
@ -72,6 +65,25 @@ internal class DefaultSingleAccountListProducer @AssistedInject constructor(
is UserWallet.Hot -> hotWalletId.authType != HotWalletId.AuthType.NoPassword 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 @AssistedFactory
interface Factory : SingleAccountListProducer.Factory { interface Factory : SingleAccountListProducer.Factory {
override fun create(params: SingleAccountListProducer.Params): DefaultSingleAccountListProducer override fun create(params: SingleAccountListProducer.Params): DefaultSingleAccountListProducer

View file

@ -1,13 +1,16 @@
package com.tangem.data.account.producer package com.tangem.data.account.producer
import arrow.core.getOrElse
import com.google.common.truth.Truth import com.google.common.truth.Truth
import com.tangem.domain.account.models.AccountList import com.tangem.domain.account.models.AccountList
import com.tangem.domain.account.producer.SingleAccountListProducer import com.tangem.domain.account.producer.SingleAccountListProducer
import com.tangem.domain.common.wallets.UserWalletsListRepository import com.tangem.domain.common.wallets.UserWalletsListRepository
import com.tangem.domain.core.flow.FlowProducerTools import com.tangem.domain.core.flow.FlowProducerTools
import com.tangem.domain.models.TokensSortType 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.UserWallet
import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.features.virtualaccount.VirtualAccountFeatureToggles
import com.tangem.hot.sdk.model.HotWalletId import com.tangem.hot.sdk.model.HotWalletId
import com.tangem.test.core.getEmittedValues import com.tangem.test.core.getEmittedValues
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
@ -40,12 +43,16 @@ class DefaultSingleAccountListProducerTest {
private val userWalletsListRepository = mockk<UserWalletsListRepository> { private val userWalletsListRepository = mockk<UserWalletsListRepository> {
every { userWallets } returns MutableStateFlow<List<UserWallet>?>(value = listOf(userWallet)) every { userWallets } returns MutableStateFlow<List<UserWallet>?>(value = listOf(userWallet))
} }
private val virtualAccountsFeatureToggles = mockk<VirtualAccountFeatureToggles> {
every { isVirtualAccountsEnabled } returns false
}
private val producer = DefaultSingleAccountListProducer( private val producer = DefaultSingleAccountListProducer(
params = SingleAccountListProducer.Params(userWalletId = userWalletId), params = SingleAccountListProducer.Params(userWalletId = userWalletId),
walletAccountListFlowFactory = walletAccountListFlowFactory, walletAccountListFlowFactory = walletAccountListFlowFactory,
flowProducerTools = flowProducerTools, flowProducerTools = flowProducerTools,
userWalletsListRepository = userWalletsListRepository, userWalletsListRepository = userWalletsListRepository,
virtualAccountsFeatureToggles = virtualAccountsFeatureToggles,
dispatchers = TestingCoroutineDispatcherProvider(), 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<UserWallet.Hot> {
every { walletId } returns userWalletId
every { hotWalletId } returns mockk {
every { authType } returns HotWalletId.AuthType.Password
}
}
val userWalletsListRepository = mockk<UserWalletsListRepository> {
every { userWallets } returns MutableStateFlow<List<UserWallet>?>(value = listOf(supportedWallet))
}
val virtualAccountsFeatureToggles = mockk<VirtualAccountFeatureToggles> {
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 @Test
fun `flow will updated if factoryFlow is updated`() = runTest { fun `flow will updated if factoryFlow is updated`() = runTest {
// Arrange // Arrange

View file

@ -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,
)
}
}

View file

@ -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<VirtualAccountStatusValueDM>(),
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}" },
) {}
}
}
}

View file

@ -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,
)
}
}

View file

@ -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<AccountStatus.Virtual>
get() = AccountStatus.Virtual(account = account, value = VirtualAccountStatusValue.Error.Unavailable).some()
override fun produce(): Flow<AccountStatus.Virtual> {
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"
}
}

View file

@ -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<String, AccountStatus.Virtual>
internal typealias WalletIdWithVirtualStatusDM = Map<String, VirtualAccountStatusValueDM>
/**
* 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<WalletIdWithVirtualStatus>,
private val persistenceDataStore: DataStore<WalletIdWithVirtualStatusDM>,
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<AccountStatus.Virtual?> {
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"
}
}

View file

@ -3,7 +3,6 @@ package com.tangem.domain.account.status.producer
import arrow.core.Option import arrow.core.Option
import arrow.core.none import arrow.core.none
import arrow.core.toOption import arrow.core.toOption
import com.tangem.common.card.FirmwareVersion
import com.tangem.core.analytics.api.AnalyticsExceptionHandler import com.tangem.core.analytics.api.AnalyticsExceptionHandler
import com.tangem.domain.account.models.AccountCurrencyId import com.tangem.domain.account.models.AccountCurrencyId
import com.tangem.domain.account.models.AccountList 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.core.utils.lceLoading
import com.tangem.domain.models.StatusSource import com.tangem.domain.models.StatusSource
import com.tangem.domain.models.TotalFiatBalance import com.tangem.domain.models.TotalFiatBalance
import com.tangem.domain.models.account.Account import com.tangem.domain.models.account.*
import com.tangem.domain.models.account.AccountStatus
import com.tangem.domain.models.account.VirtualAccountStatusValue
import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.network.Network 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.PriceChangeCalculator
import com.tangem.domain.tokens.operations.TokenListFactory import com.tangem.domain.tokens.operations.TokenListFactory
import com.tangem.domain.tokens.operations.TotalFiatBalanceCalculator 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.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.logging.TangemLogger import com.tangem.utils.logging.TangemLogger
import dagger.assisted.Assisted import dagger.assisted.Assisted
@ -83,6 +80,7 @@ internal class DefaultSingleAccountStatusListProducer @AssistedInject constructo
private val userWalletsListRepository: UserWalletsListRepository, private val userWalletsListRepository: UserWalletsListRepository,
private val singleAccountListSupplier: SingleAccountListSupplier, private val singleAccountListSupplier: SingleAccountListSupplier,
private val paymentAccountStatusSupplier: PaymentAccountStatusSupplier, private val paymentAccountStatusSupplier: PaymentAccountStatusSupplier,
private val virtualAccountStatusSupplier: VirtualAccountStatusSupplier,
private val networksRepository: NetworksRepository, private val networksRepository: NetworksRepository,
private val dispatchers: CoroutineDispatcherProvider, private val dispatchers: CoroutineDispatcherProvider,
private val networkStatusSupplier: MultiNetworkStatusSupplier, private val networkStatusSupplier: MultiNetworkStatusSupplier,
@ -93,8 +91,8 @@ internal class DefaultSingleAccountStatusListProducer @AssistedInject constructo
) : SingleAccountStatusListProducer { ) : SingleAccountStatusListProducer {
private val logger = TangemLogger.withTag(TAG) private val logger = TangemLogger.withTag(TAG)
private val Account.Payment.errorPaymentAccountStatus: AccountStatus.Payment
// VirtualAccount status pipeline lands in a follow-up PR; until then surface an unavailable status. get() = AccountStatus.Payment(this, PaymentAccountStatusValue.Error.Unavailable)
private val Account.Virtual.errorVirtualAccountStatus: AccountStatus.Virtual private val Account.Virtual.errorVirtualAccountStatus: AccountStatus.Virtual
get() = AccountStatus.Virtual(this, VirtualAccountStatusValue.Error.Unavailable) get() = AccountStatus.Virtual(this, VirtualAccountStatusValue.Error.Unavailable)
@ -148,141 +146,100 @@ internal class DefaultSingleAccountStatusListProducer @AssistedInject constructo
flattenCurrency = flattenCurrency, flattenCurrency = flattenCurrency,
) )
val isPaymentSupported = userWallet.isPaymentAccountSupported() val accounts = accountListFlow.value.accounts
logger.i("flattenFlow[$walletId]: isPaymentAccountSupported=$isPaymentSupported") val hasPaymentAccount = accounts.any { it is Account.Payment }
if (isPaymentSupported) { val hasVirtualAccount = accounts.any { it is Account.Virtual }
combineWithPaymentAccount( logger.i("flattenFlow[$walletId]: payment=$hasPaymentAccount, virtual=$hasVirtualAccount")
accountListFlow = accountListFlow, val specialStatusFlows = buildList<Flow<AccountStatus>> {
cryptoCurrencyStatusFlow = cryptoCurrencyStatusFlow, if (hasPaymentAccount) {
paymentAccountStatusFlow = paymentAccountStatusSupplier.invoke(userWalletId = params.userWalletId) add(
.onEach { paymentStatus -> paymentAccountStatusSupplier.invoke(userWalletId = walletId)
logger.i( .onEach { status ->
"flattenFlow[$walletId]: paymentAccountStatus emitted " + logger.i(
"valueType=${paymentStatus.value::class.simpleName}", "flattenFlow[$walletId]: paymentAccountStatus emitted " +
) "valueType=${status.value::class.simpleName}",
},
)
} else {
combineWithoutPaymentAccount(
accountListFlow = accountListFlow,
cryptoCurrencyStatusFlow = cryptoCurrencyStatusFlow,
)
}
.collect { accountStatusList -> channel.send(accountStatusList) }
}
private fun combineWithPaymentAccount(
accountListFlow: StateFlow<AccountList>,
cryptoCurrencyStatusFlow: Flow<Map<AccountCurrencyId, CryptoCurrencyStatus>>,
paymentAccountStatusFlow: Flow<AccountStatus.Payment>,
): Flow<AccountStatusList> {
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<CryptoCurrencyStatus> =
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),
) )
} },
} )
}
if (hasVirtualAccount) {
add(
virtualAccountStatusSupplier.invoke(userWalletId = walletId)
.onEach { status ->
logger.i(
"flattenFlow[$walletId]: virtualAccountStatus emitted " +
"valueType=${status.value::class.simpleName}",
)
},
)
}
}
val specialStatusesFlow: Flow<Map<AccountId, AccountStatus>> = 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( private fun buildCryptoPortfolioStatus(
userWalletId = accountList.userWalletId, account: Account.CryptoPortfolio,
accountStatuses = accountStatuses, currencyStatusMap: Map<AccountCurrencyId, CryptoCurrencyStatus>,
totalAccounts = accountList.totalAccounts, accountList: AccountList,
totalFiatBalance = TotalFiatBalanceCalculator.calculate(balances), ): AccountStatus.CryptoPortfolio {
totalArchivedAccounts = accountList.totalArchivedAccounts, if (account.cryptoCurrencies.isEmpty()) return account.toEmptyAccountStatus()
sortType = accountList.sortType,
groupType = accountList.groupType, val statuses: List<CryptoCurrencyStatus> = 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( private fun buildAccountStatusList(
accountListFlow: StateFlow<AccountList>, accountList: AccountList,
cryptoCurrencyStatusFlow: Flow<Map<AccountCurrencyId, CryptoCurrencyStatus>>, accountStatuses: List<AccountStatus>,
): Flow<AccountStatusList> { ): AccountStatusList {
return combine( val balances = accountStatuses.flattenTotalFiatBalance()
flow = accountListFlow, return AccountStatusList(
flow2 = cryptoCurrencyStatusFlow, userWalletId = accountList.userWalletId,
transform = { accountList, currencyStatusMap -> accountStatuses = accountStatuses,
logger.i( totalAccounts = accountList.totalAccounts,
"combineWithoutPayment[${params.userWalletId}] transform: " + totalFiatBalance = TotalFiatBalanceCalculator.calculate(balances),
"accounts=${accountList.accounts.size}, " + totalArchivedAccounts = accountList.totalArchivedAccounts,
"currencyStatusMap=${currencyStatusMap.size}", sortType = accountList.sortType,
) groupType = accountList.groupType,
val accountStatuses = accountList.accounts
.filterIsInstance<Account.CryptoPortfolio>()
.map { account ->
when (account) {
is Account.CryptoPortfolio -> if (account.cryptoCurrencies.isEmpty()) {
account.toEmptyAccountStatus()
} else {
val statuses: List<CryptoCurrencyStatus> =
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 UserWallet.isPaymentAccountSupported(): Boolean = when (this) {
is UserWallet.Cold -> scanResponse.card.firmwareVersion >= FirmwareVersion.HDWalletAvailable
is UserWallet.Hot -> hotWalletId.authType != HotWalletId.AuthType.NoPassword
}
private fun ProducerScope<AccountStatusList>.flattenCurrencyStatusFlow( private fun ProducerScope<AccountStatusList>.flattenCurrencyStatusFlow(
userWallet: UserWallet, userWallet: UserWallet,
flattenCurrency: MutableSharedFlow<Map<AccountCurrencyId, CryptoCurrency>>, flattenCurrency: MutableSharedFlow<Map<AccountCurrencyId, CryptoCurrency>>,

View file

@ -39,6 +39,7 @@ dependencies {
implementation(projects.features.staking.api) implementation(projects.features.staking.api)
implementation(projects.features.markets.api) implementation(projects.features.markets.api)
implementation(projects.features.swap.api) implementation(projects.features.swap.api)
implementation(projects.features.virtualAccounts.details.api) //VIRTUAL_ACCOUNTS_ENABLED
/** Project - Other */ /** Project - Other */
implementation(projects.core.configToggles) implementation(projects.core.configToggles)

View file

@ -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.MultiWalletBalanceFetcher
import com.tangem.domain.tokens.wallet.implementor.SingleWalletBalanceFetcher import com.tangem.domain.tokens.wallet.implementor.SingleWalletBalanceFetcher
import com.tangem.domain.tokens.wallet.implementor.SingleWalletWithTokenBalanceFetcher 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.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.logging.TangemLogger import com.tangem.utils.logging.TangemLogger
import kotlinx.coroutines.async import kotlinx.coroutines.async
@ -56,6 +58,8 @@ class WalletBalanceFetcher internal constructor(
private val singleWalletBalanceFetcher: BaseWalletBalanceFetcher, private val singleWalletBalanceFetcher: BaseWalletBalanceFetcher,
private val balanceFetchingOperations: BalanceFetchingOperations, private val balanceFetchingOperations: BalanceFetchingOperations,
private val paymentAccountStatusFetcher: PaymentAccountStatusFetcher, private val paymentAccountStatusFetcher: PaymentAccountStatusFetcher,
private val virtualAccountStatusFetcher: VirtualAccountStatusFetcher,
private val virtualAccountsFeatureToggles: VirtualAccountFeatureToggles,
private val dispatchers: CoroutineDispatcherProvider, private val dispatchers: CoroutineDispatcherProvider,
) : FlowFetcher<WalletBalanceFetcher.Params> { ) : FlowFetcher<WalletBalanceFetcher.Params> {
@ -70,7 +74,9 @@ class WalletBalanceFetcher internal constructor(
multiQuoteStatusFetcher: MultiQuoteStatusFetcher, multiQuoteStatusFetcher: MultiQuoteStatusFetcher,
multiStakingBalanceFetcher: MultiStakingBalanceFetcher, multiStakingBalanceFetcher: MultiStakingBalanceFetcher,
paymentAccountStatusFetcher: PaymentAccountStatusFetcher, paymentAccountStatusFetcher: PaymentAccountStatusFetcher,
virtualAccountStatusFetcher: VirtualAccountStatusFetcher,
stakingIdFactory: StakingIdFactory, stakingIdFactory: StakingIdFactory,
virtualAccountsFeatureToggles: VirtualAccountFeatureToggles,
dispatchers: CoroutineDispatcherProvider, dispatchers: CoroutineDispatcherProvider,
) : this( ) : this(
userWalletsListRepository = userWalletsListRepository, userWalletsListRepository = userWalletsListRepository,
@ -85,6 +91,8 @@ class WalletBalanceFetcher internal constructor(
stakingIdFactory = stakingIdFactory, stakingIdFactory = stakingIdFactory,
), ),
paymentAccountStatusFetcher = paymentAccountStatusFetcher, paymentAccountStatusFetcher = paymentAccountStatusFetcher,
virtualAccountStatusFetcher = virtualAccountStatusFetcher,
virtualAccountsFeatureToggles = virtualAccountsFeatureToggles,
dispatchers = dispatchers, dispatchers = dispatchers,
) )
@ -99,6 +107,8 @@ class WalletBalanceFetcher internal constructor(
multiQuoteStatusFetcher: MultiQuoteStatusFetcher, multiQuoteStatusFetcher: MultiQuoteStatusFetcher,
multiStakingBalanceFetcher: MultiStakingBalanceFetcher, multiStakingBalanceFetcher: MultiStakingBalanceFetcher,
paymentAccountStatusFetcher: PaymentAccountStatusFetcher, paymentAccountStatusFetcher: PaymentAccountStatusFetcher,
virtualAccountStatusFetcher: VirtualAccountStatusFetcher,
virtualAccountsFeatureToggles: VirtualAccountFeatureToggles,
stakingIdFactory: StakingIdFactory, stakingIdFactory: StakingIdFactory,
dispatchers: CoroutineDispatcherProvider, dispatchers: CoroutineDispatcherProvider,
) : this( ) : this(
@ -121,6 +131,8 @@ class WalletBalanceFetcher internal constructor(
stakingIdFactory = stakingIdFactory, stakingIdFactory = stakingIdFactory,
), ),
paymentAccountStatusFetcher = paymentAccountStatusFetcher, paymentAccountStatusFetcher = paymentAccountStatusFetcher,
virtualAccountStatusFetcher = virtualAccountStatusFetcher,
virtualAccountsFeatureToggles = virtualAccountsFeatureToggles,
dispatchers = dispatchers, dispatchers = dispatchers,
) )
@ -177,6 +189,14 @@ class WalletBalanceFetcher internal constructor(
balanceFetchingOperations.fetchQuotes(rawCurrencyIds = setOf(TangemPayCurrencyFactory.TOKEN_ID)) balanceFetchingOperations.fetchQuotes(rawCurrencyIds = setOf(TangemPayCurrencyFactory.TOKEN_ID))
paymentAccountStatusFetcher.invoke(PaymentAccountStatusFetcher.Params(userWalletId)) 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))
}
} }
} }

View file

@ -18,6 +18,13 @@ sealed class WalletFetchingSource {
*/ */
data object TangemPay : 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). * Standard balance fetching sources (NETWORK, QUOTE, STAKING).
* Processed via [BalanceFetchingOperations.fetchAll]. * Processed via [BalanceFetchingOperations.fetchAll].

View file

@ -29,6 +29,7 @@ internal class MultiWalletBalanceFetcher(
sources = setOf(FetchingSource.NETWORK, FetchingSource.QUOTE, FetchingSource.STAKING), sources = setOf(FetchingSource.NETWORK, FetchingSource.QUOTE, FetchingSource.STAKING),
), ),
WalletFetchingSource.TangemPay, WalletFetchingSource.TangemPay,
WalletFetchingSource.VirtualAccount,
) )
override suspend fun getCryptoCurrencies(userWallet: UserWallet): Set<CryptoCurrency> { override suspend fun getCryptoCurrencies(userWallet: UserWallet): Set<CryptoCurrency> {

View file

@ -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.MultiWalletBalanceFetcher
import com.tangem.domain.tokens.wallet.implementor.SingleWalletBalanceFetcher import com.tangem.domain.tokens.wallet.implementor.SingleWalletBalanceFetcher
import com.tangem.domain.tokens.wallet.implementor.SingleWalletWithTokenBalanceFetcher 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.assertEither
import com.tangem.test.core.assertEitherRight import com.tangem.test.core.assertEitherRight
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
@ -50,7 +52,11 @@ internal class WalletBalanceFetcherTest {
private val multiQuoteStatusFetcher: MultiQuoteStatusFetcher = mockk() private val multiQuoteStatusFetcher: MultiQuoteStatusFetcher = mockk()
private val multiStakingBalanceFetcher: MultiStakingBalanceFetcher = mockk() private val multiStakingBalanceFetcher: MultiStakingBalanceFetcher = mockk()
private val paymentAccountStatusFetcher: PaymentAccountStatusFetcher = mockk() private val paymentAccountStatusFetcher: PaymentAccountStatusFetcher = mockk()
private val virtualAccountStatusFetcher: VirtualAccountStatusFetcher = mockk()
private val stakingIdFactory: StakingIdFactory = mockk() private val stakingIdFactory: StakingIdFactory = mockk()
private val virtualAccountsFeatureToggles: VirtualAccountFeatureToggles = mockk {
every { isVirtualAccountsEnabled } returns false
}
private val fetcher = WalletBalanceFetcher( private val fetcher = WalletBalanceFetcher(
userWalletsListRepository = userWalletsListRepository, userWalletsListRepository = userWalletsListRepository,
@ -62,7 +68,9 @@ internal class WalletBalanceFetcherTest {
multiQuoteStatusFetcher = multiQuoteStatusFetcher, multiQuoteStatusFetcher = multiQuoteStatusFetcher,
multiStakingBalanceFetcher = multiStakingBalanceFetcher, multiStakingBalanceFetcher = multiStakingBalanceFetcher,
paymentAccountStatusFetcher = paymentAccountStatusFetcher, paymentAccountStatusFetcher = paymentAccountStatusFetcher,
virtualAccountStatusFetcher = virtualAccountStatusFetcher,
stakingIdFactory = stakingIdFactory, stakingIdFactory = stakingIdFactory,
virtualAccountsFeatureToggles = virtualAccountsFeatureToggles,
dispatchers = TestingCoroutineDispatcherProvider(), dispatchers = TestingCoroutineDispatcherProvider(),
) )

View file

@ -7,10 +7,10 @@ import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory
import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.tokens.FetchingSource
import com.tangem.domain.tokens.MultiWalletAccountListFetcher import com.tangem.domain.tokens.MultiWalletAccountListFetcher
import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesProducer import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesProducer
import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesSupplier import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesSupplier
import com.tangem.domain.tokens.FetchingSource
import com.tangem.domain.tokens.wallet.WalletFetchingSource import com.tangem.domain.tokens.wallet.WalletFetchingSource
import io.mockk.clearMocks import io.mockk.clearMocks
import io.mockk.coEvery import io.mockk.coEvery
@ -54,6 +54,7 @@ class MultiWalletBalanceFetcherTest {
sources = setOf(FetchingSource.NETWORK, FetchingSource.QUOTE, FetchingSource.STAKING), sources = setOf(FetchingSource.NETWORK, FetchingSource.QUOTE, FetchingSource.STAKING),
), ),
WalletFetchingSource.TangemPay, WalletFetchingSource.TangemPay,
WalletFetchingSource.VirtualAccount,
) )
Truth.assertThat(actual).isEqualTo(expected) Truth.assertThat(actual).isEqualTo(expected)
} }

View file

@ -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<VirtualAccountStatusFetcher.Params> {
suspend operator fun invoke(userWalletId: UserWalletId): Either<Throwable, Unit> {
return invoke(Params(userWalletId))
}
data class Params(val userWalletId: UserWalletId)
}

View file

@ -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<AccountStatus.Virtual> {
data class Params(val userWalletId: UserWalletId)
interface Factory : FlowProducer.Factory<Params, VirtualAccountStatusProducer>
}

View file

@ -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<VirtualAccountStatusProducer, VirtualAccountStatusProducer.Params, AccountStatus.Virtual>() {
operator fun invoke(userWalletId: UserWalletId): Flow<AccountStatus.Virtual> {
val params = VirtualAccountStatusProducer.Params(userWalletId)
return this.invoke(params)
}
}