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

@ -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<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),
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<Flow<AccountStatus>> {
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<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(
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<AccountCurrencyId, CryptoCurrencyStatus>,
accountList: AccountList,
): AccountStatus.CryptoPortfolio {
if (account.cryptoCurrencies.isEmpty()) return account.toEmptyAccountStatus()
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(
accountListFlow: StateFlow<AccountList>,
cryptoCurrencyStatusFlow: Flow<Map<AccountCurrencyId, CryptoCurrencyStatus>>,
): Flow<AccountStatusList> {
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<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 buildAccountStatusList(
accountList: AccountList,
accountStatuses: List<AccountStatus>,
): 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<AccountStatusList>.flattenCurrencyStatusFlow(
userWallet: UserWallet,
flattenCurrency: MutableSharedFlow<Map<AccountCurrencyId, CryptoCurrency>>,

View file

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

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.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<WalletBalanceFetcher.Params> {
@ -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))
}
}
}

View file

@ -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].

View file

@ -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<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.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(),
)

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.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)
}

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