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

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