Updated on 2026-08-14
This commit is contained in:
parent
c637c9a194
commit
0b3914cf6a
29 changed files with 453 additions and 37 deletions
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue