Updated on 2026-08-14

This commit is contained in:
Tangem 2026-06-05 12:35:03 +05:00
parent c637c9a194
commit 0b3914cf6a
29 changed files with 453 additions and 37 deletions

View file

@ -103,6 +103,7 @@ data class AccountList private constructor(
when (account) {
is Account.CryptoPortfolio -> account.cryptoCurrencies
is Account.Payment -> emptyList()
is Account.Virtual -> emptyList()
}
}
}
@ -156,6 +157,12 @@ data class AccountList private constructor(
"$tag: The number of payment accounts must not exceed $MAX_PAYMENT_ACCOUNTS_COUNT"
}
@Serializable
data object ExceedsMaxVirtualAccountsCount : Error {
override fun toString(): String =
"$tag: The number of virtual accounts must not exceed $MAX_VIRTUAL_ACCOUNTS_COUNT"
}
@Serializable
data object DuplicateAccountIds : Error {
override fun toString(): String = "$tag: Account list contains duplicate account IDs"
@ -175,6 +182,7 @@ data class AccountList private constructor(
companion object {
const val MAX_PAYMENT_ACCOUNTS_COUNT = 1
const val MAX_VIRTUAL_ACCOUNTS_COUNT = 1
const val MAX_CRYPTO_PORTFOLIO_ACCOUNTS_COUNT = 20
const val MAX_ARCHIVED_ACCOUNTS_COUNT = 1000
private const val MAX_MAIN_ACCOUNTS_COUNT = 1
@ -200,6 +208,9 @@ data class AccountList private constructor(
val paymentAccounts = accounts.filterIsInstance<Account.Payment>()
ensure(paymentAccounts.size <= MAX_PAYMENT_ACCOUNTS_COUNT) { Error.ExceedsMaxPaymentAccountsCount }
val virtualAccounts = accounts.filterIsInstance<Account.Virtual>()
ensure(virtualAccounts.size <= MAX_VIRTUAL_ACCOUNTS_COUNT) { Error.ExceedsMaxVirtualAccountsCount }
val cryptoAccounts = accounts.filterIsInstance<Account.CryptoPortfolio>()
ensure(cryptoAccounts.size <= MAX_CRYPTO_PORTFOLIO_ACCOUNTS_COUNT) { Error.ExceedsMaxAccountsCount }

View file

@ -63,5 +63,6 @@ fun AccountStatusList.hasMultiCurrencyAccount(): Boolean = accountStatuses.any {
when (status) {
is AccountStatus.CryptoPortfolio -> status.tokenList.flattenCurrencies().size > 1
is AccountStatus.Payment -> false
is AccountStatus.Virtual -> false
}
}

View file

@ -128,6 +128,14 @@ internal class AccountListTest {
accounts = createAccounts(count = 21),
expected = AccountList.Error.ExceedsMaxAccountsCount.left(),
),
CreateTestModel(
accounts = listOf(
Account.CryptoPortfolio.createMainAccount(userWalletId),
Account.Virtual(userWalletId),
Account.Virtual(userWalletId),
),
expected = AccountList.Error.ExceedsMaxVirtualAccountsCount.left(),
),
CreateTestModel(
accounts = listOf(
createAccount(derivationIndex = 1),

View file

@ -18,6 +18,7 @@ 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.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.network.Network
@ -93,6 +94,10 @@ internal class DefaultSingleAccountStatusListProducer @AssistedInject constructo
private val logger = TangemLogger.withTag(TAG)
// VirtualAccount status pipeline lands in a follow-up PR; until then surface an unavailable status.
private val Account.Virtual.errorVirtualAccountStatus: AccountStatus.Virtual
get() = AccountStatus.Virtual(this, VirtualAccountStatusValue.Error.Unavailable)
override val fallback: Option<AccountStatusList> = none()
override fun produce(): Flow<AccountStatusList> {
@ -185,6 +190,7 @@ internal class DefaultSingleAccountStatusListProducer @AssistedInject constructo
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 {
@ -410,6 +416,7 @@ internal class DefaultSingleAccountStatusListProducer @AssistedInject constructo
when (accountStatus) {
is AccountStatus.CryptoPortfolio -> accountStatus.tokenList.totalFiatBalance
is AccountStatus.Payment -> accountStatus.value.totalFiatBalance
is AccountStatus.Virtual -> accountStatus.value.totalFiatBalance
}
}
}
@ -435,6 +442,7 @@ internal class DefaultSingleAccountStatusListProducer @AssistedInject constructo
)
}
is Account.Payment -> null
is Account.Virtual -> null
}
},
totalAccounts = accountList.totalAccounts,

View file

@ -176,7 +176,7 @@ sealed interface Account {
}
@Serializable
data class Payment(
data class Payment private constructor(
override val accountId: AccountId,
) : Account {
override val accountName: AccountName.Custom = AccountName.Custom("Payment").getOrElse {
@ -189,10 +189,26 @@ sealed interface Account {
}
}
}
@Serializable
data class Virtual private constructor(
override val accountId: AccountId,
) : Account {
override val accountName: AccountName.Custom = AccountName.Custom("Virtual").getOrElse {
error("Can not create account name for Virtual account with userWalletId = ${accountId.userWalletId}")
}
companion object {
operator fun invoke(userWalletId: UserWalletId): Virtual {
return Virtual(accountId = AccountId.forVirtualAccount(userWalletId = userWalletId))
}
}
}
}
val Account.derivationIndex: DerivationIndex?
get() = when (this) {
is Account.CryptoPortfolio -> derivationIndex
is Account.Payment -> null
is Account.Virtual -> null
}

View file

@ -38,6 +38,7 @@ data class AccountId private constructor(
companion object {
const val PaymentAccountIdPrefix = "payment_"
const val VirtualAccountIdPrefix = "virtual_"
private val sha256Digest: MessageDigest by lazy { MessageDigest.getInstance("SHA-256") }
private val hexRegex = Regex("^[a-fA-F0-9]{64}$")
@ -77,5 +78,9 @@ data class AccountId private constructor(
fun forPaymentAccount(userWalletId: UserWalletId): AccountId {
return AccountId(value = "$PaymentAccountIdPrefix$userWalletId", userWalletId = userWalletId)
}
fun forVirtualAccount(userWalletId: UserWalletId): AccountId {
return AccountId(value = "$VirtualAccountIdPrefix$userWalletId", userWalletId = userWalletId)
}
}
}

View file

@ -44,6 +44,12 @@ sealed interface AccountStatus {
override val account: Account.Payment,
val value: PaymentAccountStatusValue,
) : AccountStatus
@Serializable
data class Virtual(
override val account: Account.Virtual,
val value: VirtualAccountStatusValue,
) : AccountStatus
}
fun Iterable<AccountStatus>.filterCryptoPortfolio(): List<AccountStatus.CryptoPortfolio> {

View file

@ -0,0 +1,230 @@
package com.tangem.domain.models.account
import com.tangem.domain.models.StatusSource
import com.tangem.domain.models.TotalFiatBalance
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.kyc.KycStatus
import com.tangem.domain.models.network.NetworkAddress
import com.tangem.domain.models.serialization.SerializedBigDecimal
import kotlinx.serialization.Serializable
import java.math.BigDecimal
/**
* Represents the various states a virtual account (VA) can have, encapsulating different information based on
* the state. Mirrors [PaymentAccountStatusValue] but carries VA-specific states (no card-related variants).
*
* @property source The source of the status information.
*/
@Serializable
sealed class VirtualAccountStatusValue {
abstract val source: StatusSource
/** The total fiat balance associated with this status. */
val totalFiatBalance: TotalFiatBalance
get() = when (this) {
is Empty,
is NotCreated,
is UnderReview,
is Provisioning,
is CountryNotSupported,
is Error,
-> TotalFiatBalance.Loaded(amount = SerializedBigDecimal.ZERO, source = source)
is Loading -> TotalFiatBalance.Loading
is Active -> {
val rate = fiatRate ?: return TotalFiatBalance.Failed
TotalFiatBalance.Loaded(amount = fiatBalance.availableBalance.multiply(rate), source = source)
}
}
/**
* Copies the status with a new [source].
*
* @param source The new source of the status information.
*/
fun copySealed(source: StatusSource): VirtualAccountStatusValue {
return when (this) {
is UnderReview -> copy(source = source)
is Provisioning -> copy(source = source)
is Active -> copy(source = source)
is Loading,
is Empty,
is NotCreated,
is CountryNotSupported,
is Error,
-> this
}
}
/** Represents an empty virtual account status when no specific state is available. */
@Serializable
data object Empty : VirtualAccountStatusValue() {
override val source: StatusSource = StatusSource.ACTUAL
}
/** Represents the Loading state of a virtual account, typically while fetching its details. */
@Serializable
data object Loading : VirtualAccountStatusValue() {
override val source: StatusSource = StatusSource.ACTUAL
}
/** Represents a state where the virtual account has not been created yet. */
@Serializable
data object NotCreated : VirtualAccountStatusValue() {
override val source: StatusSource = StatusSource.ACTUAL
}
/**
* Represents a state where the virtual account is under review (KYC).
*
* @property source The source of the status information.
* @property kycStatus The current KYC status.
* @property customerId The unique identifier of the customer.
*/
@Serializable
data class UnderReview(
override val source: StatusSource,
val kycStatus: KycStatus,
val customerId: String,
) : VirtualAccountStatusValue()
/**
* Represents a state where the virtual account is being provisioned on the backend (e.g. via Rain),
* after KYC approval and terms acceptance.
*
* @property source The source of the status information.
*/
@Serializable
data class Provisioning(override val source: StatusSource) : VirtualAccountStatusValue()
/** Represents a state where the user's country is not eligible for a virtual account. */
@Serializable
data object CountryNotSupported : VirtualAccountStatusValue() {
override val source: StatusSource = StatusSource.ACTUAL
}
/**
* Represents a state where the virtual account is successfully loaded with complete information.
*
* @property source The source of the status information.
* @property customerId The unique identifier of the customer.
* @property currencyCode The code of the currency.
* @property depositAddress The on-chain address for deposits, if available.
* @property fiatBalance The fiat balance details.
* @property cryptoBalance The crypto balance details.
* @property availableForWithdrawal The crypto amount currently available for withdrawal/swap.
* @property cryptoCurrency The crypto currency held in the account (e.g. USDC).
* @property fiatRate Exchange rate of [cryptoCurrency] to the app's selected fiat currency,
* or `null` if the quote is not yet available. When `null`,
* [totalFiatBalance] resolves to [TotalFiatBalance.Failed].
*/
@Serializable
data class Active(
override val source: StatusSource,
val customerId: String,
val currencyCode: String,
val depositAddress: String?,
val fiatBalance: FiatBalance,
val cryptoBalance: CryptoBalance,
val availableForWithdrawal: SerializedBigDecimal,
val cryptoCurrency: CryptoCurrency.Token,
val fiatRate: SerializedBigDecimal?,
) : VirtualAccountStatusValue() {
val cryptoCurrencyStatus: CryptoCurrencyStatus = CryptoCurrencyStatus(
currency = cryptoCurrency,
value = buildCryptoCurrencyStatusValue(
amount = availableForWithdrawal,
fiatAmount = fiatBalance.availableBalance,
fiatRate = fiatRate,
depositAddress = cryptoBalance.depositAddress,
),
)
}
/** Represents an error state for the virtual account status. */
@Serializable
sealed class Error : VirtualAccountStatusValue() {
/** Error state indicating the device is exposed. */
@Serializable
data object ExposedDevice : Error() {
override val source: StatusSource = StatusSource.ACTUAL
}
/** Error state indicating the account is unavailable. */
@Serializable
data object Unavailable : Error() {
override val source: StatusSource = StatusSource.ACTUAL
}
/** Error state indicating the account data is not synced. */
@Serializable
data object NotSynced : Error() {
override val source: StatusSource = StatusSource.ACTUAL
}
}
/**
* Represents the fiat balance of the virtual account.
*
* @property availableBalance The amount of available balance in fiat.
* @property currency The currency of the balance.
*/
@Serializable
data class FiatBalance(val availableBalance: SerializedBigDecimal, val currency: String)
/**
* Represents the crypto balance of the virtual account.
*
* @property id The unique identifier of the crypto asset.
* @property chainId The identifier of the blockchain network.
* @property depositAddress The on-chain address for deposits.
* @property tokenContractAddress The contract address of the token.
* @property balance The amount of the crypto balance.
*/
@Serializable
data class CryptoBalance(
val id: String,
val chainId: Long,
val depositAddress: String,
val tokenContractAddress: String,
val balance: SerializedBigDecimal,
)
}
private fun buildCryptoCurrencyStatusValue(
amount: SerializedBigDecimal,
fiatAmount: SerializedBigDecimal,
fiatRate: SerializedBigDecimal?,
depositAddress: String,
): CryptoCurrencyStatus.Value {
val networkAddress = NetworkAddress.Single(
defaultAddress = NetworkAddress.Address(
type = NetworkAddress.Address.Type.Primary,
value = depositAddress,
),
)
return if (fiatRate != null) {
CryptoCurrencyStatus.Loaded(
amount = amount,
fiatAmount = fiatAmount,
fiatRate = fiatRate,
priceChange = BigDecimal.ZERO,
networkAddress = networkAddress,
sources = CryptoCurrencyStatus.Sources(),
pendingTransactions = emptySet(),
stakingBalance = null,
yieldSupplyStatus = null,
hasCurrentNetworkTransactions = false,
)
} else {
CryptoCurrencyStatus.NoQuote(
amount = amount,
networkAddress = networkAddress,
stakingBalance = null,
yieldSupplyStatus = null,
hasCurrentNetworkTransactions = false,
pendingTransactions = emptySet(),
sources = CryptoCurrencyStatus.Sources(),
)
}
}

View file

@ -0,0 +1,86 @@
package com.tangem.domain.models.account
import com.google.common.truth.Truth
import com.tangem.domain.models.StatusSource
import com.tangem.domain.models.TotalFiatBalance
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import io.mockk.mockk
import org.junit.jupiter.api.Test
import java.math.BigDecimal
/**
* Verifies that [VirtualAccountStatusValue.Active] converts its fiat balance to the app's selected currency
* via [VirtualAccountStatusValue.Active.fiatRate] (mirror of the Payment account fix, [REDACTED_TASK_KEY]).
*/
class VirtualAccountStatusValueTest {
private val cryptoCurrency = mockk<CryptoCurrency.Token>(relaxed = true)
private fun activeWith(fiatRate: BigDecimal?, balance: BigDecimal = BigDecimal("100")) =
VirtualAccountStatusValue.Active(
source = StatusSource.ACTUAL,
customerId = "customer",
currencyCode = "USD",
depositAddress = "0xabc",
fiatBalance = VirtualAccountStatusValue.FiatBalance(availableBalance = balance, currency = "USD"),
cryptoBalance = VirtualAccountStatusValue.CryptoBalance(
id = "usd-coin",
chainId = 137L,
depositAddress = "0xabc",
tokenContractAddress = "0xdef",
balance = balance,
),
availableForWithdrawal = balance,
cryptoCurrency = cryptoCurrency,
fiatRate = fiatRate,
)
@Test
fun `totalFiatBalance converts balance via fiatRate when rate is present`() {
// Arrange
val rate = BigDecimal("0.9")
val active = activeWith(fiatRate = rate, balance = BigDecimal("100"))
// Act
val result = active.totalFiatBalance
// Assert
Truth.assertThat(result).isInstanceOf(TotalFiatBalance.Loaded::class.java)
Truth.assertThat((result as TotalFiatBalance.Loaded).amount)
.isEqualTo(BigDecimal("100").multiply(rate))
}
@Test
fun `totalFiatBalance is Failed when fiatRate is null`() {
// Arrange
val active = activeWith(fiatRate = null)
// Act & Assert
Truth.assertThat(active.totalFiatBalance).isEqualTo(TotalFiatBalance.Failed)
}
@Test
fun `cryptoCurrencyStatus is NoQuote when fiatRate is null`() {
// Arrange
val active = activeWith(fiatRate = null)
// Act & Assert
Truth.assertThat(active.cryptoCurrencyStatus.value)
.isInstanceOf(CryptoCurrencyStatus.NoQuote::class.java)
}
@Test
fun `cryptoCurrencyStatus is Loaded with the rate when fiatRate is present`() {
// Arrange
val rate = BigDecimal("0.9")
val active = activeWith(fiatRate = rate)
// Act
val value = active.cryptoCurrencyStatus.value
// Assert
Truth.assertThat(value).isInstanceOf(CryptoCurrencyStatus.Loaded::class.java)
Truth.assertThat((value as CryptoCurrencyStatus.Loaded).fiatRate).isEqualTo(rate)
}
}